Compare commits

...
Author SHA1 Message Date
logan b5bdba2f38 fix(queue): name the queue header's two older actions
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m44s
CI / e2e (pull_request) Successful in 10m13s
Clear queue and Add queue to playlist were named by a `title` attribute
and nothing else, while the close button beside them has carried an
`aria-label` since #24. They get one too.

`title` is a name, so this is the weak-name case rather than the missing
one: it is the *last* fallback in the accname order, so any content put
inside the button later silently outranks it, and a phone has no hover
to show it. The `title`s stay — on a desktop they are also the tooltip
for an icon-only control, which is a different job.

The assertion is the part worth reading. The obvious spec — `getByRole`
by name, which is what `queue-overlay.spec.ts` already does for the
close button — is **green on the broken build**: measured against the
running pre-fix app, both buttons matched. So the second test states the
property as what it is, that the name is not the tooltip: it removes the
`title` attributes and asks again, which was 0 and 0 on main and is 1
and 1 now.

Closes #170
2026-08-26 05:39:42 -04:00
logan 245647f12b Merge pull request 'feat(ui): warm album art ahead of the scroll' (#215) from feat/65-art-prefetch-ahead into main
CI / check (push) Successful in 2m43s
CI / e2e (push) Successful in 10m14s
2026-08-25 17:58:42 +00:00
logan 3479ae8d39 feat(ui): warm album art ahead of the scroll
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 10m13s
Scrolling the albums grid pops art in: the cards already draw the
smallest adequate tier and are already lazy, so what was left is *when*
the request happens. The grids are virtualized, so the `<img>` — and
therefore the fetch — does not exist until the virtualizer renders its
card, which is about 1000px past the viewport, or two screens on the
reference device.

The issue asks for a larger overscan and that is not available:
`_overhang` is a hard-coded `protected` field on `BaseLayout` with no
configuration surface. So the request is issued ahead of the element
instead. `utils/image-prefetch.ts` warms a bounded window either side
of the rendered range, from `rangeChanged` rather than
`visibilityChanged` — the two report different ranges, and a window
measured from what is *visible* is spent on cards that already exist.

Cover and artist URLs are served under `Cache-Control: immutable`
(content-hashed filenames), so a prefetched image is a cache hit by the
time its card is drawn. The bytes are the browser's; what this holds is
the set of URLs asked for, capped and reported to `__yjCacheStats()`.

Measured on the bulk seed (4 988 albums), ten 2 400px jumps, covers in
the viewport with `naturalWidth === 0`: 254 of 258 blank one frame
after the jump and 214 two frames after, against 117 and 77 with the
prefetch.

Closes #65
2026-08-25 13:55:43 -04:00
logan e23e6f9a54 Merge pull request 'feat(android): the phone's "More" is a bottom sheet' (#211) from feat/71-more-as-a-bottom-sheet into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 17:55:31 +00:00
logan 52d095e3c6 feat(android): the phone's "More" is a bottom sheet
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 10m14s
The tab bar's fifth item opened `<app-sidebar>` in a `wa-drawer`
sliding in from the side, which is a desktop shape put on a phone: a
200px column of a 424px screen, opening away from the thumb that asked
for it, with the rest of its 400px band empty. It also had three nested
scrollers in it -- the dialog, its body, and the sidebar's own
`overflow-y: auto` host -- so which box a drag moved depended on where
the finger landed, which is the "only part of the screen scrolls under
my finger" in the report.

It is the same element with `placement="bottom"` and `without-header`,
so the surface is the sheet #60 already built rather than a second
pattern: a `wa-drawer` is a native `<dialog>` opened with `showModal()`,
which is exactly the top layer that finding rests on, so the focus
trap, Escape, tap-outside and `wa-after-hide` come along unchanged and
nothing new has to be proved about paint containment.

The sidebar is still mounted rather than re-listed as data, because the
shell's own copy is `display: none` below 600px rather than removed --
a second list drawing `nav-*` handles is the duplicate-testid failure
this component already renders conditionally to avoid. What `expanded`
means had to grow to say the host owns the *box*: `app-sidebar` writes
an inline width and caps itself at 400px, which beats any rule the host
could write, so the width, the scrolling and the mouse-only resize
handle now follow that attribute. The rows are 48px below 600px, stated
in the sidebar's own stylesheet since that is the only place it renders
there.

Measured in the running app at 424x439: the sheet is 424 wide, 373 tall
(85vh, so there is an outside to tap), rows 48px, one scroller with
`overscroll-behavior: contain`, and Settings' row reachable at the end
of it. Desktop and Compact are untouched.

Closes #71
2026-08-25 13:48:51 -04:00
logan 939915b1fa Merge pull request 'feat(android): the tap highlight goes, a press state replaces it' (#214) from feat/54-native-touch-feel into main
CI / check (push) Successful in 2m58s
CI / e2e (push) Canceled after 0s
2026-08-25 17:48:45 +00:00
logan 3aa2a434b4 feat(android): the tap highlight goes, a press state replaces it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m44s
CI / e2e (pull_request) Successful in 10m10s
The phone drew a grey box over the bounding rect of whatever was
tapped, which is the web view saying what it is. It is gone in one
declaration: `-webkit-tap-highlight-color` is inherited and an
inherited property crosses a shadow boundary, so `html` in index.css
reaches every shadow root in the app. Measured three roots deep,
rgba(0, 0, 0, 0.18) before and rgba(0, 0, 0, 0) after.

Removing it removes the only touch feedback several surfaces had, so
the press state is part of the same change rather than a later polish
item — with the highlight gone a held row measured the *hover* tint,
which on a phone is synthesised by the hold itself and outlives it.
The four lists' rows, the tab bar, the sidebar's destinations and the
shared context-menu item take --yj-press-overlay on :active; the cards
already had scale(0.97). The press selector carries a state class
because a row is .track-row.selected.active, so a bare :active shows
nothing on the row a phone is most likely to press. And those
surfaces' hover tints move behind (hover: hover) and (pointer: fine),
which is #68's gate applied to a tint rather than a revealed control.

user-select, the other half of the Findings, was already done: the
first rule in index.css covers the shadow roots for the same reason.
touch-action: manipulation is declined — the 300ms delay it is offered
for is already absent on a width=device-width viewport, and what it
would really change is the gesture stack tuned by measurement on a
device this session cannot measure.

Closes #54
2026-08-25 12:51:28 -04:00
logan 944995dc3c Merge pull request 'feat(android): a name is not a link on a phone, the menu carries it' (#208) from feat/67-entity-links-into-menus into main
CI / check (push) Successful in 2m40s
CI / e2e (push) Successful in 10m18s
2026-08-25 16:51:16 +00:00
logan 8de412cf36 feat(android): a name is not a link on a phone, the menu carries it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m5s
CI / e2e (pull_request) Successful in 10m3s
Every track, album and artist name in the app navigates through
`utils/explore-link.ts`, and every sentence of how it does that is a
desktop compromise: the navigation is held for one double-click
interval so double-clicking the row can still play it, and the target
is a few characters of text inside a row. On touch that is a delay on
an ambiguous target, and since #63 the row's own tap claims the click
anyway -- so the link was unreachable as well as fiddly.

So below the phone breakpoint a name renders as plain text and the
row's context menu carries the destination instead: `go-to-menu.ts`
draws "Go to Artist" / "Go to Album" under exactly the condition the
link is not, using `explore-link`'s own exported routing so an untagged
entity reaches the library page by the same lookup.

Three things this leans on. Suppressing a link with no menu behind it
is not a smaller affordance but a destination the phone cannot reach,
so `keepOnPhone` is the exception for the three surfaces with no row
menu. The items are drawn for a single selection only, which is the
Play item's rule one step on. And there is no "Go to Genre", because
no row renders a genre link to lose -- that would be new navigation
rather than a replacement.

Closes #67
2026-08-25 12:40:10 -04:00
logan aeb173c684 Merge pull request 'fix(ui): the phone's context sheet says when it scrolls' (#209) from fix/207-sheet-scroll-affordance into main
CI / e2e (push) Canceled after 0s
CI / check (push) Successful in 2m52s
2026-08-25 16:39:44 +00:00
logan 025ed59480 Merge pull request 'test(ui): refresh two stale baselines and settle whether they gate' (#205) from test/196-visual-tier-gates into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 16:39:32 +00:00
logan a82d29abd7 docs(agent): drop #204's workaround from the baseline rule
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m43s
CI / e2e (pull_request) Successful in 10m1s
#204 landed first, so the ui-tier rule can name `make ui-visual-update
UI_ARGS=<path>` rather than the raw vitest invocation it needed while
the recipe swallowed its filter.
2026-08-25 12:38:17 -04:00
logan d365850321 test(ui): refresh two stale baselines and settle whether they gate
`make ui-visual` had been red on main since #27, and nothing runs it,
so four references had drifted across three unrelated merges. Two were
refreshed with #186; these are the other two.

Each recorded two changes, not one. `app-sidebar` lost Jobs (#27,
shipped) and moved its highlight from Home to Tracks; `now-playing`
gained the source line (shipped) and was playing from "a dynamic mix".
Both are singleton stores read by a case that sets nothing, so the shot
photographs whatever the case above it left behind — blessing that
would have pinned the file's own ordering into a PNG. Both cases state
their world now, and only then are the references re-recorded.

The second half of the issue asks whether this tier should gate, and
the answer is measured rather than preferred: replayed in a bare
ubuntu:24.04 container — CI's `check` image — three of the ten
baselines fail on rendering alone (`track-info` and one `page-header`
shot at ratio 0.03 against a 0.02 allowance, `seek-bar` one pixel
shorter), and the two stale ones disagree about their new height
between the machines. So CI cannot run this suite without a second,
container-recorded baseline set that every local run would then fail
against, and a pre-push hook is the same fault with the machines
swapped. It stays local and opt-in; what replaces the gate is the rule
that a change moving a component's geometry refreshes that component's
baseline in the same commit, having read the image, and never one it
did not cause. Written where a person meets it: the skill's tier doc
has the table, SKILL.md has the obligation, CLAUDE.md has the
constraint.

Deleting the baselines was the third option and is declined: this tier
has caught one thing no other could, the `<span>` that lost the UA
stylesheet's `box-sizing` and grew a badge 36→38px.

Closes #196
2026-08-25 12:38:06 -04:00
logan 3a2d3e8ef8 Merge pull request 'fix(metadata): read a WAV's tags out of its RIFF id3 chunk' (#218) from fix/104-wav-tags-read into main
CI / check (push) Canceled after 1m18s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:56 +00:00
logan 871a3b7aac Merge pull request 'test(ui): make ui-visual-update honour its file filter' (#206) from fix/204-ui-visual-update-filter into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:53 +00:00
logan f3207e8bf9 Merge pull request 'test(ui): clear localStorage between component tests' (#219) from fix/138-ui-test-storage-leak into main
CI / check (push) Canceled after 3s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:42 +00:00
logan 772c71c49f test(ui): clear localStorage between component tests
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m41s
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 survives from file to
file. `track-list` restores its sort in `connectedCallback` and
`aria-tail.test.ts` activates the Title column header, so any file that
mounts a track list later in that tab opens sorted by title — where
`track-11` precedes `track-3`, which is #138's failure exactly.

Nothing about it is specific to that pair: a probe that throws when a
test starts with a non-empty `localStorage` failed 24 test-starts in one
full run (11 with the two sort keys, 8 with `cover-grid-size`, 5 with
`track-list-column-widths`) and cascaded into 248 failures. Which files
share a tab, and in what order, changes run to run, which is the whole
of why this reads as a 1-in-3 flake and passes in isolation.

The clear belongs in `setup.ts` rather than in the specs that write,
because the spec that reads is never the one that knows — and it is
safe for the same reason the leak exists: files within a session are
sequential, so it cannot wipe storage a concurrent file is using.

The spec now also states the order it asserts rather than inheriting a
default, and checks the row it is about to double-click carries the path
it expects, so a stray sort fails by naming itself instead of as an
off-by-eight file path.

Closes #138
2026-08-24 06:47:05 -04:00
logan c56eae2959 fix(metadata): read a WAV's tags out of its RIFF id3 chunk
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m46s
tagwriter has always written a WAV's tags into a RIFF "id3 " chunk
correctly, and dhowden/tag -- which metadata.ExtractTags is built on --
has no RIFF reader at all.  So the app could not see tags it had just
written: editing tags on a WAV, autotagging a WAV folder or importing a
WAV download all appeared to succeed and changed nothing the library
could show, while the file on disk really was tagged and other players
read it.

backend/riff is a new package rather than a move into either half,
because tagwriter already imports metadata: reaching back for parseRIFF
is an import cycle, not merely the wrong direction.  backend/tagtotals
is the precedent.

Its 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 doing that on the scan path would read every WAV in the
library in full.  ID3Chunk seeks over what it is not looking for.

The container is asked 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.  An untagged
WAV -- no chunk, an RF64 container, a tag with every frame cleared --
reads as empty metadata with no TagReadWarning: the scanner's filename
fallback is the right answer there, and a warning would report a fault
on a healthy file.

The gap was pinned by TestWAVTagsAreNotReadableYet, which failed the
moment the reader learned and said in its own comment what to update.
So it goes, TestFixturesMatchManifest no longer skips wav, and
totals_test.go's WAV case reads through metadata.ExtractTags like the
other three formats -- a round trip asserted through the writer's own
parser was a test of the writer, which is why nothing caught this.

Closes #104
2026-08-24 05:44:39 -04:00
logan 8c85db8968 docs(ui): quote the shipped fade's own measurement
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m33s
The comment on `wa-dialog::part(body)` carried bottom-edge pixels from
an intermediate probe (29,33,36) while `.planning/NOTES.md` recorded
the final sample against the shipped rule (22,24,27) — the same
gradient, read a few pixels higher up the box. A measurement written in
two places has to agree, or neither can be trusted.
2026-08-23 07:04:04 -04:00
logan 02e2251bb2 fix(ui): the phone's context sheet says when it scrolls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m40s
The bottom sheet's body has scrolled since #60 and said nothing about
it. Measured at 424x439, the track list's menu ended at y=470 with the
fold at 439 — reachable, since the body is `overflow-y: auto`, but with
no affordance saying so, and worst where the cut lands on a row
boundary and the sheet ends in a clean edge that reads as the end of
the list.

The cap stays: `menu-surface`'s own comment says a surface covering the
whole screen is a page, not a sheet. What changes is that the body
draws a fade, from two background layers whose *attachments* are the
feature — a shadow pinned to the box (`scroll`) under a cover of the
sheet's own colour painted at the end of the content (`local`), which
scrolls up over the shadow exactly when there is nothing more to see.
So the fade is absent on a menu that fits, present the moment one does
not, and gone again at the end of the list, with no scroll listener and
nothing reaching into `wa-dialog`'s shadow root for the scroller.
`background-attachment` is Chrome 4; the reference device is Chrome 113.

The other two options in the report — a shortened last row, or a max
height that makes the cut obvious — both need `height mod 48`, which
CSS cannot express, and the observed case is exactly the one where the
cut already lands on a row boundary.

The curve is steep rather than linear because the rows under it stay
live: a scrim over a menu item is that item's text surface, so the
4.5:1 rule reaches it, and the light ramp is what makes that real.
A 48px linear scrim at 0.8 greyed the last label to 5.0:1; 32px already
down to a quarter strength at 14px measures 9.9:1 there and spends its
weight on the strip below it.

The test asserts the pair of attachments rather than the pixels, on
this file's existing grounds that no tier here renders like the device
— it fails on the pre-fix stylesheet with `expected 'scroll' to be
'local, scroll'`. The rendered result was measured in the harness and
is recorded in `.planning/NOTES.md`.

Closes #207
2026-08-23 06:45:46 -04:00
logan e5d0f2714b test(ui): make ui-visual-update honour its file filter
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 9m27s
vitest parses a bare `--update` as taking the next positional as its
value, so `make ui-visual-update UI_ARGS=<path>` handed the path to the
flag and ran with no filter at all: 99 files, every baseline in the repo
re-recorded, any stale one blessed in silence. Two paths were worse
still — the first was eaten and only the second ran.

That is #196's own hazard living in the tool meant to resolve it: the
rule is "refresh the reference your change moved and never one you did
not cause", and the documented way to refresh one refreshed the set.

`--update=true` is the whole fix, with the reason beside it because
`=true` reads like something to tidy away. `make ui-visual` and
`make ui-test` are unaffected — their `$(UI_ARGS)` follows `run`, with
no flag to swallow it — and no other target interpolates a variable
after a boolean flag.

Closes #204
2026-08-23 04:36:46 -04:00
logan ee1d8b3179 Merge pull request 'Android touch model, phases 2-4: swipe to queue, and the other three lists' (#201) from 63-touch-model-phase-2 into main
CI / e2e (push) Successful in 9m28s
CI / check (push) Successful in 2m31s
Build & publish the Android APK / apk (push) Successful in 1m27s
Build & publish Arch package / arch-package (push) Successful in 2m39s
Attach the desktop build to the release / linux (push) Successful in 58s
Sync Homebrew formula / sync-formula (push) Successful in 6s
2026-08-22 05:54:47 +00:00
logan 29feb4b94b feat(android): the touch model reaches the other three lists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 9m20s
Plan 019 phases 3 and 4, which finish #63. The queue panel and both
playlist detail views get tap-to-play and hold-to-select; the playlist
views get swipe-to-queue as well.

Phase 3 was not the pure wiring the plan expected, in two places.

A tap on a queue row plays that position. Copying track-list's tap --
which sets the queue to the list the row is in -- would rebuild the
queue from the queue, discarding its source, its shuffle order and
anything inserted by hand. It reads as a no-op and is not one.

And the queue panel has no swipe, deliberately. A right swipe means add
to the queue everywhere else it exists, and a queue row is already in
the queue; the only thing it could mean there is remove, which is the
same gesture with the opposite effect one screen away. Removing a queue
row is on the row, on its sheet since #60, and now on its selection
bar. The assertion is that its rows do not opt in.

The reveal became utils/swipe-to-queue.ts rather than being copied into
three lists, keyed on a data-swipe attribute so one stylesheet carries
the touch-action half of the device fix to rows that are called two
different things.

Phase 4 was already true and is now asserted: a claimed tap has its
click swallowed, so an explore-link inside a row never sees one and
tap-to-play wins with no rule of its own. Its test was vacuous when
written -- the tap helper sent no click, so there was nothing to
swallow -- which also weakened phase 1's. It sends one now.

Escape leaves selection mode, from selection-bar rather than from each
of the four hosts, since that element exists only while the mode does.
The platform's back gesture deliberately does not reach it: the shell
owns the history stack and four lists reaching for history is four
stacks. That is #200.

Verified on the reference phone: a queue row taps to its own index and
refuses a swipe, a playlist row queues on a swipe and plays its
playlist on a tap, and a hold raises the bar without the menu.

Closes #63
2026-08-22 01:41:21 -04:00
logan 4e667759c4 feat(android): swipe a track row right to queue it
Plan 019 phase 2. A finger on a track row now drags a reveal out from
under it and queues the track on release, with the affordance saying
what it will do before it does it.

Two things the device said that the plan did not predict, and both
change the implementation rather than decorate it.

The gesture runs on touch events, not pointer events. Chrome 113's
WebView cancels the pointer stream ~16px into any drag whatever
touch-action says -- measured at auto, pan-y and none alike -- while
touchmove keeps firing. So touch-action: pan-y is half the fix and a
non-passive touchmove calling preventDefault is the other half, and
neither works alone: with the preventDefault in place and touch-action
back at auto the gesture died after one move. Both are correct in
Chromium either way, which is why the module's header carries the
measurement and the component tier asserts the stylesheet.

And a phase 1 defect the device found on the way past: the native
contextmenu arrives in either order and only one was handled. Our
500ms timer firing first, a component claiming it, and Chrome
delivering its own menu 50-70ms later was suppressed by nothing -- so
the context menu opened over the selection bar, two holds in four, on
the one surface this issue exists to have changed. Six holds clean
after.

draggable="true" is not a competitor: no dragstart fires from a touch
drag on this WebView at all.
2026-08-22 01:23:19 -04:00
logan ff3875b55d Merge pull request 'Android touch model, phase 1: tap to play, hold to select' (#199) from 63-android-touch-model into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 9m29s
2026-08-22 04:36:37 +00:00
logan 76e1c444cc feat(android): tap to play, hold to select
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s
Phase 1 of #63, and the design the issue asks for as one piece is
.planning/plans/active/019-android-touch-model.md.

**A finger has no second button and no modifier keys**, so the primary
action has to be the primary gesture: tap plays the row, and the hold
that opened a context menu now enters selection mode with that row
selected.

Three decisions in it, and two diverge from the report.

**The predicate is the pointer, not the platform or the viewport.**
`pointerType === 'touch'`, per event, which is already how long-press.ts
decided and is the only such test in the frontend. This is #64's rule --
named after the capability -- and it carries #64's warning: keyed on a
width, an Android *tablet* at 600px gets click-selects/double-click-plays
on a touchscreen, which is the inversion this issue exists to fix, on
the platform it exists for. A touchscreen laptop cannot be described by
a width at all. Per event, a mouse keeps desktop semantics on the very
same row, and there is no second declaration of what a phone does.

**There is no double-tap, and the number is why.** The report asks for
single tap to play *and* double tap for the menu. Those cannot both be
honoured: the first tap of a double tap is indistinguishable from a
single tap until the interval expires, so "tap plays" becomes "tap
waits". Measured on the device, the play command to TrackChanged is
155/123/85/56/91 ms -- median ~100 -- and the app's own
DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction,
250ms of it spent deliberately doing nothing, on every track anyone
plays, to reach a menu the hold already reaches. So the menu and the
selection action bar are the same surface, which is also the platform's
convention and removes a concept rather than adding one.

**Tap-to-play and selection mode ship together**, because splitting
them is a regression dressed as an increment: a touch user selects by
tapping today and acts through the long-press menu, so moving tap to
play on its own would leave a window with no way to select forty tracks
at all.

**What lets this reassign the hold without touching one of the fourteen
context menus**: the layer announces `yj-tap` / `yj-long-press`
(composed, cancelable) and acts on nothing. A component claims one with
preventDefault. An **unclaimed long press still becomes a
`contextmenu`**, so the card grids, Explore, the playlist rows and
every other menu behave exactly as they did, and only lists that opt in
get selection mode. An unclaimed *tap* does nothing at all and the
click follows normally, which is what leaves every button in the app
alone -- only a claimed tap has its click swallowed, or playing a track
would also select it.

**And the device found the one thing no browser tier can see.**
Chrome 113's Android WebView fires its own `contextmenu` on a long
press. long-press.ts stood down when a trusted one arrived, which was
right while both paths ended in a context menu; they no longer do, so
standing down means the gesture silently does the *old* thing.
Measured, before the fix, holding a track row:

    {"log":["contextmenu isTrusted=true"],
     "state":{"bar":null,"menuActive":true,"selected":1}}

`yj-long-press` was never announced, the menu opened, and all 26 tests
passed -- dispatched pointer events do not make a browser synthesise
one. So the native event is a **trigger, not a competitor**: the
gesture is announced from it and only a claim suppresses it. Unclaimed
it propagates untouched, which is the same "browser wins" outcome
reached by asking instead of assuming.

The tier could not find that and can hold it, because this module has
always told its own events apart by identity rather than isTrusted, so
an untrusted one from a test takes exactly the browser's path.

Verified on the device by *performing* the gestures rather than
describing the page -- `adb shell input tap` and `input swipe x y x y
700` reach the WebView as real pointer events, which is new here and is
written down in the plan with the pixel mapping. Tap plays; a hold
raises the bar with one selected and no menu; a tap toggles to two,
back to one, and the mode ends with the last row; an album card still
opens its context menu.

29 new tests. The e2e spec is rewritten to assert **both** halves --
the row selects, and a card elsewhere still opens the real menu --
because a spec that only checked the row would pass on a build that had
silently broken the other thirteen.

Phases 2-4 (swipe to queue, the other three surfaces, and what #67
inherits) are in the plan and not in this commit.
2026-08-22 00:23:33 -04:00
logan 4f32d4e13c Merge pull request 'Android: raise every remaining control to the 44px touch floor' (#198) from 186-touch-targets-settings into main
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 9m18s
Closes #186
2026-08-22 03:22:16 +00:00
logan 4f628b1f52 fix(ui): raise the last controls below the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m36s
The rest of #186's second table, and one thing it could not have said.

    .section-toggle          187x15  autotag
    .folders-menu-trigger     32x18  autotag
    .back-button              32x32  artist-details
    Requests / Downloads tabs 85x34, 96x34
    .search-mode-tab          89x26, 79x26  explore
    explore search input     325x18  in a 36px box

**back-button was six controls, not one.** The issue names it in
artist-details because that is the view the sweep opened; the same
declaration is byte-identical in artist-details, genre-details,
playlist-details, smart-playlist-details, explore-artist-details and
explore-album-details, 32px in all six. So it is styles/back-button.
css.ts now, adopted by each, and a source sweep fails on a seventh
copy -- because the failure this invites is not a size changing, it is
somebody adding a detail view and writing `.back-button` out again,
which no device sweep would catch for the same reason this one did
not. That is icon-language.test.ts's shape, and the argument for it
here is the inverse of the column arrows': one declaration covering
36 controls is cheap to fix, and six declarations of one control are
six chances to miss five.

It is a real 44px box rather than padding with the width handed back:
a detail header runs no fit pass, and this button has a visible
background, so a hit area larger than the circle would be a control
bigger than it looks. The size is #55's, reached there for the same
reason -- "the way out is 44px on a phone".

**The explore search box was two faults.** The row was 36px *and* the
input inside it was 18, so eight pixels at each edge were not a target
at all: a tap near the top of the box landed on the container and did
nothing. The container is 44 and the input stretches to it.

**The Downloads tabs take padding rather than a min-size**, because
the mark for the selected tab is its bottom border -- a min-size
centres the label and leaves the underline 10px beneath it.

page-action-check-now (113x29) is in that table and is not here: it is
a PageAction, so #195 raised it with the rest of the header's actions
and touch-targets.test.ts already covers it.

**The Downloads tabs needed a min-size as well as the padding, and CI
is what said so.** Padding alone made them 44px on this machine and
**43px in the container**: the total is 13 + 13 + 2 + whatever line box
the font gives 13px text, and ubuntu:24.04's is a pixel shorter than
Arch's. A height computed from a font's line box is not a height you
control -- which is #195's "stated as a property on the strength of one
engine" one layer down, in the same PR that recorded it. The padding
stays, because it is what keeps the underline against the label; the
min-size is the floor.

Caught by the new test rather than by a person, which is the half of
this that worked.

Verified on the device, sweeping each view the way the issue was
filed: explore, downloads, autotag and artist-details now report
**one** control under the floor apiece, and it is the skip link, which
#186 already ruled out as keyboard-only. .search-mode-tab 89x44 and
79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44,
.section-toggle 174x44, .folders-menu-trigger 44x44, .back-button
44x44.

All 12 new tests fail on main, the source sweep naming all six copies.
make ui-test 1041 pass; make e2e 236 pass on chromium, which is half
an answer -- CI had the other half, and used it.

Closes #186
2026-08-21 23:08:25 -04:00
logan 2100f0022f fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the
other half of #186 and much the larger one: swept on the reference
device (TLP301, 424x439) with all eleven config-sections expanded,
**120 controls** were under the floor -- not the 93 the issue's table
implies, and config-field is eight of them.

The bulk is behind the disclosures, which is why nobody had counted it:

    36  .column-arrow-btn          16x14   <- smallest in the app
    29  .column-toggle             16x16
    26  shortcut-capture button    80x25
     8  download format checkbox   16x16
     7  config-field select        335x30
     6  wa-input / wa-button       204x20, 185x21

**The density argument, measured rather than guessed, and it is
smaller than it looks.** The rows were already near the floor --
.column-item is 335x36 and .shortcut-row 335x37; it is the controls
*inside* them that were 14-25px. So a control grows into the row it
already occupies and the row goes 36 to 44. Measured after: the two
column lists went 373->447 and 690->850, +234px over the whole page.
Half a screen of extra scroll on a page that already scrolls, against
36 targets of 16x14.

**Settings is cheaper than the header was, and for a stated reason.**
There is no overflow fit on this page, so the header's "only width is
contested" rule does not bind at all and nothing here needs padding
with a negative margin. Height is a min-size, and the two square
controls can simply be square.

Three shapes, because one rule does not fit three kinds of control:

**A native checkbox is targeted through its label.** It cannot grow
its hit area without growing its paint, and a 44px checkbox is not
what anyone wants -- so .column-label is a real <label for> now and
the column's *name* is the target, 70x44 rather than 16x16. That is
the argument config-field already makes one file over ("a real label
association also makes the label text a click target, which is
behaviour, not annotation"), and here it is the whole fix. The
download formats already had the label; they only needed the height.

**The arrows take padding, which is invisible.** They carry
background: none and a transparent border, so 16x14 -> 44x44 changes
nothing anyone can see until hover -- #186's Direction exactly.

**Web Awesome's controls come from the library's own API.** Their
height is decided inside somebody else's shadow root, and
--wa-form-control-height is the variable that decides it. A custom
property inherits through a shadow boundary, so a :host declaration
reaches them; styles/wa-touch-floor.css.ts is that, once, adopted
rather than written at :root in index.css -- a :root rule would be
invisible to the component tier, which renders a component and no page
stylesheet.

**Two controls no sweep can see are fixed by name**, and they are the
trap this issue keeps setting. config-field's toggle has an <input>
that is opacity: 0; width: 0; height: 0, so a walk of every input
skips it as a zero-sized node -- what a finger hits is the <label>,
which measured **34x19**, smaller than anything in either of #186's
tables and absent from both. It is 44x44 with the pill still painted
at 2.5em x 1.4em and negative inline margins keeping it flush with the
inputs above. And shortcut-capture's reset button renders only for a
shortcut somebody has rebound, so a sweep of a fresh install never
meets it.

Verified on the device, same method as the sweep that filed it:
120 controls under the floor before, 42 after. All 42 are accounted
for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four
are wa-input's inner input at 204x**42**, which is the control
measured *inside* its own 1px border (part=base is 238x44), and one is
the skip link, which #186 already ruled out as keyboard-only.

The e2e suite passes, top-bar-fit and header-action-overflow included
-- but that is **chromium**, which is half an answer, and saying so is
the whole of what #195's second commit was about. What can be argued
rather than run: library-filter is the only thing here in a container
that measures itself, and its width did not change. The fit measures
inline size.

Two page-header screenshots are refreshed because they are this
issue's own debris -- #195's taller sort control, merged last session,
with its references never re-recorded. app-sidebar's and
now-playing's are deliberately left: they are unrelated drift, and
blessing an unrelated screenshot is how the sidebar reference came to
still list a destination #27 retired. That is #196.
2026-08-21 22:55:54 -04:00
logan 52038dc5ae Merge pull request 'Android: raise the page header and the phone search button to the touch floor' (#195) from 186-touch-targets-page-header into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m21s
2026-08-22 00:32:08 +00:00
logan 0d331666d6 fix(shell): make the header's touch targets cost no width
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m28s
The first pass grew the two square controls to 44px as boxes, which
added 22px to the header. That fit at every width Chromium was checked
at and **clipped the overflow trigger at 320x600 in WebKit** -- the
engine closest to what ships, and the one no machine here can run:

    every action is reachable at 320x600 (400% zoom)
    - Array []
    + Array [ "more" ]

Two things were wrong, and only one of them was the code.

**The claim was checked on one engine and stated as a property.** The
previous commit said #69's fit "does not move ... the check rather than
the assumption", on the strength of running that spec against chromium
alone. CI runs both browsers precisely because they are not the same
answer.

**And the box was the wrong thing to grow**, which the issue already
said: "reached by growing the *hit* area rather than the visual weight
where the two can differ -- padding on the control, not size on the
icon". #69's pass measures inline size, so a taller control is free and
a wider one is not.

So height stays a box -- the header has the room and nothing measures
it -- and width is padding with a negative margin handing the space
back, which is the seek bar's shape from #187. Measured in the
component tier at 320px: the arrow's rect is 45x44 and it occupies 29,
the overflow trigger 44x44 occupying 38, the search button 44x44
occupying 40. Those three occupancies are what they were before any of
this, so the fit pass sees a header identical to main's and the
320px case cannot regress.

The arrow's target is lopsided for #187's reason: the select is 6px to
its left and there is open space to its right, so it takes the side
with nothing to steal from. The overflow trigger's can be symmetric,
the actions row having an 8px gap.

`search-trigger` is border-box, so its 44px min-width is the whole
target and the margin alone gives the four pixels back.

The new assertion is the one that would have caught this: every grown
control must carry negative inline margins, because that is what keeps
the box out of the fit. The rect assertions stay -- getBoundingClientRect
includes padding, so the target is still measured directly rather than
inferred.
2026-08-21 20:12:51 -04:00
logan 6a5a3c33dc fix(shell): raise the page header's controls to the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Failing after 9m43s
#56 sized the playback transport for a thumb and named 44px; the queue
header keeps it. Nothing else was resized, so the controls a user meets
on *every* screen sat between a third and two thirds of the app's own
floor. Measured on the reference device at 424x439: page-sort 99x23,
page-sort-direction **28x21**, page-actions-more 38x27, and
search-trigger 40x40.

**Both questions the issue left open are answered by one measurement.**
The header is 63px tall and its controls are 20-23px, so the vertical
room was already there; the select and its direction arrow are 6px
apart, so the horizontal room was not.

That makes this min-size rather than padding with a negative margin,
which is what the seek bar needed (#187), and the difference decides
everything else. There the painted track had to stay thin, so the
target was grown past its own box and had to be checked against its
neighbours. Here the control *is* the target: the boxes are flex items,
so the gap keeps them apart and **no two targets can overlap by
construction**.

From which:

**There is no phone branch.** A 44px control on a desktop is merely
large, and a second declaration of what a phone shows is a second thing
to keep in step -- which is why this component has never had one. It
also avoids a media query no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.

**#69's overflow fit does not move.** That pass measures inline size,
so the height costs it nothing, and only the two square controls grow
the header's content -- by 22px in total. header-action-overflow.spec.ts
passes unchanged at all four of its widths, which was the check rather
than the assumption. Verified on the device that the count is still
shown at 424px, so nothing has started yielding.

search-trigger is the sharpest case and is fixed in the same pass: #57
created it as the phone's replacement for the header search box, so it
exists *only* where there is a thumb, and it shipped at 40x40 under a
comment calling that "the smallest a touch target should be". That was
the floor restated four pixels short rather than a second opinion about
it, and the comment now says so.

Unlike #187 this can be measured rather than inferred: the controls are
plain elements and the rule is a min-size, so it holds at every width
and a real Chromium rendering a real page-header gives the actual
answer. The tests fail with the device's own numbers -- 29x21, 38, 40.

Verified on the device: every control in the header is now at least
44x44, and so is the phone's search button.

**This is the Direction's first step, not all of it.** config-field's
93 Settings controls and explore-view's search row are the second pass;
Settings is a form with one shape for every row and wants its own
argument. #186 stays open for them.
2026-08-21 19:45:20 -04:00
logan 1668b9e0d2 Merge pull request 'Android: a seek bar you can actually hit, and the phone rule that never applied' (#193) from 187-seek-bar-hit-area into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m29s
2026-08-21 22:25:52 +00:00
logan ec64dbded0 fix(player): give the seek bar a thumb-sized hit area
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 9m28s
On now-playing-view -- the screen that exists so a phone has somewhere
to seek from -- the slider measured 261x6 on the reference device. Six
pixels is the whole of the drag target on the app's primary seeking
affordance, against the 44px floor the app set for itself in #56 and
holds to in the queue panel.

**The phone rule had never applied**, which is why the issue read as
"the thickening stops short" rather than "there is no thickening".
seek-bar's stylesheet asked for a 12px track below 599px and then set
6px in a plain `wa-slider` rule *written after it*. A media query adds
no specificity, so the plain rule won at every width: the source said
12 and the device said 6. That is index.css's documented rule -- "the
phone section is last on purpose" -- met inside a component's own
stylesheet, where nothing in any tier renders differently to say so.
The block is last now, and the 12px track it always asked for is real.

**And 12px is still under the floor**, so the target is built around
the painted track rather than by thickening it. The two are allowed to
differ and a slider is the clearest case where they should: a 44px
progress bar would be wrong-looking and would cost the album art the
vertical space #51 spent an issue recovering.

Two things about how it is built, both settled by measurement on the
device rather than by choosing a number.

**The padding goes on ::part(slider), not on the host.** That is the
issue's untested claim, and the answer is the pessimistic one: the
inner div is what carries the gesture -- it holds the listener and the
touch-action: none -- and it is exactly the host's size, so padding the
host would grow a box that does not take the press.

**The padding is asymmetric and the margins cancel it**, so the row does
not grow by the difference. The seek row is 19px -- its clocks, not the
track, decide that -- and the play button's top edge is 8px below it,
while `.art` above is a non-interactive div. A symmetric 44px target
reaches into the play button, and growing the row instead cost the art
25px of 143 when it was tried. So the target takes the space above.

Verified on the device at 424x439: hit area 261x44 where it was 261x6,
painted track 12px, seek row still 19px, album art still 143px, 7px of
clearance left under the play button, a press 26px above the track
seeks, and a hit test on the play button's top edge still reaches the
play button.

The desktop bottom bar is untouched: the rule is inside the phone query
and that instance is display:none below 600px anyway.

The test asserts the parsed stylesheet, on hover-affordance.test.ts's
precedent and with the same limitation stated -- no tier here lays out a
real wa-slider at a phone width, and a number measured on a phone is
not a number CI can assert. What it holds is the shape: that the phone
block is last, that padding plus track clears 44, that the margins
cancel the padding, and that the growth is upward. All four are
invisible on a desktop, and the first is exactly what a tidy-up undoes.

Closes #187
2026-08-21 18:12:29 -04:00
logan dad852a8a0 Merge pull request 'Explore: two things that have not worked since plan 013, and the temp directory Android never had' (#192) from 189-190-explore-correctness into main
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 9m33s
2026-08-21 21:43:38 +00:00
logan 30c6b665f1 fix(system): give the process a temp directory that exists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m0s
CI / e2e (pull_request) Successful in 9m37s
Android has no /tmp and hands an app no TMPDIR. Go's os.TempDir() falls
back to "/tmp" when the variable is unset, so every library in this
process that wants scratch space was being handed a path that has never
existed.

SQLite is the one that noticed, and it said so precisely:

    W/yellowjacket: msg="champion index rebuild failed"
      explore.search-index.error="populate champion fts: disk I/O error (6410)"

6410 is not a generic I/O error. `6410 & 0xff` is 10, SQLITE_IOERR, and
`6410 >> 8` is 25 -- SQLITE_IOERR_GETTEMPPATH. SQLite could not work out
where to put a temporary file. Two measurements on the device say why:
`ls -d /tmp` does not exist, and the app process's environment carries
no TMPDIR. A shell's does (/data/local/tmp), which is why this is easy
to miss from `adb shell`.

The cost was a silent performance cliff on the slowest device this app
runs on: `championReady` stayed false, so every Explore search took the
generic path over the whole 1,079,667-row index instead of the champion
subset, and the rebuild was re-attempted on every launch.

**The class is fixed rather than the statement.** The trigger is the
*size* of the work, not that query -- anything that spills fails the
same way there, so large sorts, large joins and VACUUM were all waiting
their turn. The repair belongs at the process's one answer to "where do
temporary files go".

`PRAGMA temp_store = MEMORY` was the alternative: cheaper, more local,
and a promise that every future spill fits in RAM on a phone. The
catalog is the largest thing in this app and that is not a promise
worth making silently.

UseTempDir sits beside UseHomeOverride and carries its two rules for
the same reasons. **An empty base is a no-op**, because that is what
application.Mobile.StoragePath() returns on desktop -- so this needs no
build tag and changes nothing off mobile, where /tmp is real. And **an
explicit TMPDIR wins**, so anyone who set one deliberately gets it;
nothing sets it on the platform this exists for. It needs no new Wails
API and no Java change: StoragePath() is already what YJ_HOME is
pointed at, and the directory goes under it.

Two things beyond the rename of a variable.

**Writability is probed, not assumed.** MkdirAll on an existing
unwritable directory succeeds, so without the probe this could set
TMPDIR to a directory nothing can use -- which is the same bug one
directory over, and just as quiet.

**It returns its error, and main logs it.** A temp directory that could
not be created is the same silent failure one step earlier. A failure
is not fatal: it leaves the platform's answer in place, which is what
every release before this one ran with. That log line is readable on
the platform only because of #160.

Verified on the reference device, where the same launch that used to
print the failure now prints:

    I/yellowjacket: msg="champion index rebuilt"
      explore.search-index.elapsed=6.496s

Closes #190
2026-08-21 17:23:23 -04:00
logan d034d6e571 fix(explore): resolve pending release MBIDs against the real table
The release-group MBID backfill queried `release_groups`, which plan 013
renamed to `albums`. It failed on its first statement on every launch
since e7748f1 and the pass returned quietly having done nothing:

    W/yellowjacket: msg="release-group mbid backfill: query failed"
      explore.error="SQL logic error: no such table: release_groups (1)"

What it does is resolve a release-level MBID (MUSICBRAINZ_ALBUMID, which
many taggers write instead of MUSICBRAINZ_RELEASEGROUPID) into the
release-group MBID everything else on the album page is keyed by. A scan
cannot afford a live MusicBrainz call, so `library.updateMBIDs` stashes
the release MBID in `pending_release_mbid` and defers to this. With this
broken the marker was written by every scan and resolved by nothing, so
those albums were untagged as far as the catalog is concerned,
permanently.

**The fix is to call the queries plan 013 already wrote.**
`GetAlbumsWithPendingReleaseMBID` and `ResolveAlbumPendingReleaseMBID`
have been in sql/queries/albums.sql since that change, generated and
never called -- the writer of the marker was repointed at `albums` and
the reader was not. So this is not a missed rename so much as a call
site left behind, and thirty lines of raw SQL and hand-rolled scanning
become three.

That is also the durable half. These two were the last raw-SQL
references to a schema table in the tree, and being raw is exactly why
013 missed them: sqlc reads sql/schemas/ and cannot generate against a
table that is not declared, which is what made every other statement in
the repo immune to the same rename.

Three smaller things.

**The LIMIT came back.** The raw statement bounded a run at
releaseGroupMBIDBackfillMaxPerRun and 013's sqlc replacement had no
LIMIT at all, so switching over as-written would have swapped a dead
pass for an unbounded one -- each row is a live MusicBrainz lookup on a
1 req/s limiter shared with every page the user can open.

**The UPDATE goes through the writer.** `ReadQueries` is a query-only
pool and an UPDATE issued on it fails at runtime with "attempt to write
a readonly database".

**The query is its own method so its failure is assertable.**
A test of the pass as a whole cannot see this bug, because a query
error and an empty library are the same early return -- which is the
whole reason it survived. `pendingReleaseMBIDs` returns the error, and
the test reproduces the device's exact message against the old
statement.

Verified on the reference device: the warning is gone from logcat.

Closes #189
2026-08-21 17:23:05 -04:00
logan 25ea1f3511 Merge pull request 'Android: make the app say what it is doing, then count what the audio path misses' (#191) from 135-android-underrun-instrumentation into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 9m21s
2026-08-21 20:47:45 +00:00
logan 842fe47e9e feat(player): count what the ring buffer misses
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m38s
CI / e2e (pull_request) Successful in 9m36s
An underrun is audible and nothing counted it. When the ring is empty
BufferedStreamer.Stream zeroes the caller's buffer and returns ok, so a
run of zeros is spliced into the waveform and the step discontinuity at
each edge is a click; a series of short ones is static. That is the one
candidate in #135 whose audible signature matches the report.

`starved` and `starvedSince` already existed, from #122's stall fix,
and could not answer this: they are a *stall* detector, reset by every
arriving sample, because their job is to end a track whose source has
died and a merely slow source must not be cut short. That reset is
exactly what made the audible case invisible -- a hundred 20ms
underruns a minute never approach the give-up threshold, so they were
invisible to the log, to the UI and to every tier.

UnderrunStats counts runs, calls and samples for the life of the
streamer. Runs is the number that means something audible: one episode
is one pop however many callbacks it spans, and the ratio of runs to
calls is what separates clicking from dropping out.

Three things about the reporting are deliberate.

**Counting is in Stream and reporting is not.** Stream runs on the
speaker callback's real-time deadline, so a log line there would
allocate, format and write on the exact path whose missed deadline is
the defect -- measuring by making it worse. The count is two increments
under a lock that was already held; the report is on the 1 Hz position
ticker, which only runs while playing.

**An unchanged count is not logged**, which is emitStatus' rule one
package over. A healthy player is silent, so anything in the log is
news and the line appears exactly while it is popping.

**It is Info rather than Debug.** The default level is Info and a phone
has no convenient way to set YJ_LOG_LEVEL, so a debug line here would
be a counter nobody on the affected platform could read -- which is the
shape of the bug that made #160 necessary.

underrunDelta clamps at zero because the counter belongs to the
streamer and the streamer is replaced on every track: a baseline
carried across that boundary is the previous track's total subtracted
from a fresh zero. The baseline is reset at the load as well; a
negative count in a log line reads as a broken instrument and would
discredit the measurement this exists to make.

This is the instrument, not a fix. What it measures is on the issue.
2026-08-21 16:30:32 -04:00
logan 168e588387 feat(android): route slog to logcat
Every slog line the app wrote on Android went to /dev/null, including
the one naming the error it was about to os.Exit on. #52 is what that
cost: a process that vanished with no tombstone, no AndroidRuntime
stack and nothing in `logcat -b crash`, at Priority/Critical for
months, whose entire diagnosis was one sLogger.Error main.go was
already writing.

backend/androidlog is a slog.Handler over __android_log_write, chosen
in main() by build tag rather than by a runtime check so that a desktop
binary links no cgo for a platform it cannot run on.

**Everything except the write itself is untagged.** That is
androidpayload.go's discipline pushed as far as it goes: the only
toolchain that compiles the android tag is a cross-compiler and the
only thing that runs it is a phone, so the priority mapping, the
formatting, the chunking and the handler's own attr and group
bookkeeping are ordinary Go that `go test` exercises everywhere, and
android.go is fifteen lines that hand a string to liblog.

Four things in it are load-bearing.

**The tag is a fixed string, not the application id.** The debug build
carries `applicationIdSuffix ".dev"` so it can be installed beside the
release app, and it is the only build whose WebView can be inspected --
so a tag derived from the id is a different tag on the one build
anybody debugging this app is running, and the filter meant to show
these lines would hide them exactly where they were being looked for.

**The priorities are android/log.h's own values, asserted twice.**
android.go carries constant expressions that do not compile as uint if
the header renumbers; the untagged test writes the six numbers out
longhand, because comparing a constant to itself passes on any
renumbering. A wrong priority is the failure that hides rather than
breaks -- logcat prints whatever number it is handed, so an Error filed
as Info is present, correct, and invisible to every filter.

**Formatting is delegated to slog's TextHandler.** WithAttrs and
WithGroup are the half of slog.Handler that is easy to get subtly
wrong, and a logger whose groups are wrong is a logger nobody reads.
The derived handlers share the parent's buffer *and its mutex*: a
second mutex would guard nothing, and two loggers derived from one
would splice their bytes into a single line under load.

**A line is chunked, because liblog drops what does not fit.** The
kernel logger's entry is 4068 bytes for tag and message together and
the remainder goes without comment, so a long record would be truncated
in the middle of the thing worth reading.

Time and level are dropped from the formatted line, since logcat stamps
every entry with both -- and dropping them by *key* also ate a caller's
own "level" attribute, which the on-device probe caught and
TestACallersOwnLevelAttrSurvives now holds. ReplaceAttr sees an empty
group path for the built-ins and for every top-level attribute alike,
so the kinds are what separate them.

Verified on the reference device (TLP301, Android 14): a debug build
logs I/W/E under the `yellowjacket` tag at the right priorities, and
the first thing it surfaced was a real warning nobody could previously
see -- `champion index rebuild failed ... disk I/O error (6410)`.

Closes #160
2026-08-21 16:30:32 -04:00
logan 7eb55bd378 Merge pull request 'Android: a Now Playing that survives a 439px screen, and the audit behind it' (#188) from 51-android-small-screens into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m41s
2026-08-21 20:19:46 +00:00
logan f31331c83b docs(skill): what a fresh install is doing before you measure it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m32s
Three things about a fresh install cost a measurement each, and none of
them was written down: it downloads the real catalog, so job-band is
103px of a 439px screen and every vertical number is wrong; stopping
that build returns cleanly and it **starts again within seconds**, so it
has to be stopped immediately before a measurement rather than once at
the start; and a library added over the bridge does not dismiss the
first-run wizard, which then sits over whatever you are looking at with
a correctly disabled button, reading exactly like a swallowed tap.

Also the scoped-storage path that works, the appops grant whose absence
sends the app to the system "All files access" screen on launch, and
why EXPR='...' cannot carry an apostrophe -- a file path with one in it
fails as a JavaScript error. The positional form takes a file.
2026-08-21 15:58:48 -04:00
logan 6a22601af7 docs(player): a device number is not a number CI can assert
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Canceled after 7m48s
The spec's floor on the art's height passed locally at 114 and failed in
CI at 64. Both honest: the e2e app is long-lived so an earlier spec's
job is still on screen, and volume-control renders in a browser where it
does not on Android. Same trap as the staged-job entry above, arriving
as a measurement rather than as a stuck job.
2026-08-21 15:55:56 -04:00
logan ce9951b93a test(player): assert the mechanism, not the room CI happened to have
The floor on the art's height passed locally at 114 and failed in CI at
64. Both numbers are honest and neither is about this change: the e2e
app is long-lived, so a job staged by an earlier spec is still on
screen, and the volume control renders here where it does not on
Android. Both are chrome above and below the view, and both move the
leftover.

So the claim is stated as what the reflow does rather than as what it
measures -- in a row the art is bounded by the row's height and fills
it, where in a column it is the leftover after the names. That is the
mechanism behind 53px to 143px, and it fails on the old build with
"there is no row to fill". The device numbers stay on #51, which is the
only tier that can honestly produce them.

This is the second draft of that assertion to be thrown away; the first
compared the art against the column's leftover and passed on the defect,
because the subtraction goes negative exactly when the names are taller
than the art.

Also stops the arrangement wait from requiring the row to exist, so
reverting the component to check that these tests bite still produces
the crop measurements -- 263x39, 358x315, 300x36 -- rather than eight
timeouts. 5 of 8 fail on the build before this change.
2026-08-21 15:55:43 -04:00
logan 99a45401c7 docs(player): correct the audit's scope, and the probe's false positives
The sweep covered the detail views, Downloads and Autotag as well as
the ten primary views; the note said "ten primary views plus the
queue". The null result is unchanged and now covers more.

Also records the two false positives the probe produced before it was
right, since the next audit will write the same two checks: "painted
outside the viewport" flags a horizontally scrolling carousel, so the
question is whether a scrollable ancestor can bring it back; and a hit
test at a control's centre flags everything below the fold in a scroll
container.
2026-08-21 15:46:50 -04:00
logan dd76bd2fa7 docs(player): record the crop, the reflow, and the audit's null result
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m39s
CI / e2e (pull_request) Failing after 9m47s
The audit #51 asks for, at 424x439 on the reference device with a real
1,577-track library: all ten primary views plus the queue. What it did
*not* find is worth recording, because it is the promise plan 018 makes
-- the shell does not overflow on any view, nothing is stranded outside
a scrollable ancestor, and a hit test at each control's centre reaches
the control. The width work of #57, #62, #55 and #59 holds; what was
left was vertical.

What it found is filed rather than fixed here: #186, every control that
is not the transport is under the 44px floor, and #187, the seek bar's
drag target is 6px.

Also the two device traps that cost time despite being written down --
a fresh install downloads the real catalog and the job band then eats
103px of a 439px screen, and it *restarts* after being stopped; and the
first-run wizard does not re-check for a library it did not create, so
adding one over the bridge leaves it up with a correctly disabled
button.

Closes #51
2026-08-21 15:42:15 -04:00
logan dee176c0f7 test(player): pin the art's shape and the short-screen arrangement
Four of these eight fail on the build before the fix, with the numbers
the issue is about: 263x39 at 424x439, 358x315 at 390x700, 300x36 at
900x500, and no row at all below 500px. The fifth viewport, 412x869,
passes on both -- which is the boundary landing exactly where the
arithmetic says it should, since the leftover only exceeds the width
above ~843.

Three of them cannot fail on the old build and are said to be guards
rather than evidence: that the transport does not scroll off (the old
build shrank the art instead, so it did not scroll either), that a tall
phone keeps its column, and -- after a first draft that passed on the
defect because the subtraction went negative -- a floor on the art at
the device's own viewport instead of a comparison with a layout that is
no longer there.

The wait is on the arrangement rather than on a non-zero box: a
previous test leaves the other layout on screen and a stale column
satisfies "has a size" perfectly, which showed up as one test passing
alone and failing in file order.

What this tier cannot see is the device's engine. Nothing here depends
on Chrome 113 behaviour -- the sizing rules were chosen by measuring
that engine directly, and the numbers are on the issue.
2026-08-21 15:41:59 -04:00
logan 75a24f98b6 fix(player): give Now Playing a layout that survives a short screen
Two things, and the first was a defect underneath the design question
rather than an answer to it.

**The album art was never square.** aspect-ratio is specified not to
re-derive the width when max-height clamps the height, unlike an
intrinsic ratio, which is preserved under both bounds. So a definite
`width: min(100%, 60vh)` kept its width while the height was clipped
and object-fit: cover cropped a square cover into the band -- 264x53
on the reference device, which is what #172's "39px of art" actually
looked like. It is not only the phone either: the leftover exceeds the
width only above ~843px of viewport, so every height from ~500 to ~843
drew a crop. Both maxes with auto sizes is the fix, chosen by measuring
four candidate rules against Chrome 113 itself at five column heights.

The placeholder cannot use that rule -- with no intrinsic size it
collapses to its icon, 13x58 -- so it is driven from the height, with
min-width: 0 because a flex item's automatic minimum is its content,
and max-height: calc(100vw - 2rem) because a non-replaced box cannot
express "the largest square that fits" and went 380x484 on a tall
phone without it.

**Then the reflow.** The stacked budget is fixed, so the art gets
`height - 386` and that is 53px at 424x439. #172 named two ways out;
a floor on the art scrolls the transport off the bottom, and controls
never scrolling off is #51's own Direction and plan 018's promise --
so below 500px the art and the names share a row, where the art is
bounded by the row's height rather than the column's leftover. 53px to
143px on the device, nothing scrolling, the transport untouched.

500 is where the two layouts cross rather than a round number, and it
is keyed on height alone because it answers vertical room: a 900x450
window has the same problem and the same fix.
2026-08-21 15:41:47 -04:00
logan 327785e5ec Merge pull request 'fix(queue): draw the scrim only where it can be tapped' (#182) from fix/171-phone-queue-scrim into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m29s
Build & publish Arch package / arch-package (push) Successful in 2m37s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 8s
2026-08-21 16:47:46 +00:00
logan 7ba5d321f6 test(queue): pin the breakpoint listener the scrim rule rests on
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m18s
The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed.
2026-08-21 16:24:04 +00:00
logan f126dd7397 fix(queue): draw the scrim only where it can be tapped
Below 600px `.panel-content` is `width: 100%`, so the scrim sat
entirely underneath an opaque panel -- measured at 424x439, host,
panel and scrim all 424x318. It dimmed nothing and dismissed nothing
there while wearing `cursor: pointer`, so #24's tap-outside-to-close
did not exist on the device it was drawn for.

Of the issue's two directions this takes the second. A gutter is the
drawer pattern and buys the affordance by taking width off a
full-screen surface on a 424px viewport; #55 already made the queue a
*screen* at that width, whose ways out are back and a 44px close
button. So there is no scrim there rather than an unreachable one.

Existence is `matchMedia` rather than `display: none`, on `job-band`'s
rule: a hidden scrim is still an element carrying the handler. The
600-899 band, where the panel is a 320px column of a wider content
area and the scrim has real uncovered pixels, is untouched.

The e2e half asserts *absence* at 424x439 rather than clicking,
because a phone-width case that clicks the scrim's centre hits the
panel and passes on the broken build -- which the issue anticipates.

Closes #171
2026-08-21 16:24:04 +00:00
logan 510d3470f9 Merge pull request 'fix(ui): keep a touch-only affordance reachable, or absent' (#181) from fix/137-touch-only-affordances into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m28s
2026-08-21 16:23:43 +00:00
logan d78830aa52 fix(ui): make the touch pen a corner chip, not a scrim over the art
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m19s
Always-visible is not the same as always-in-the-way: the overlay is inset:0 at 50% black, so gating it on hover left every touch device with the artwork it is editing permanently darkened. It is only a hint — .cover-art-edit carries the click, so tapping the art always worked — while the × really is the only route to its action and stays. The chip borrows the remove button's size, disc and alpha.

Also corrects the claim that no tier can render as a touch device: no committed one does, which is a choice about projects rather than a limit.
2026-08-21 15:59:45 +00:00
logan a72d1f68ed fix(ui): keep a touch-only affordance reachable, or absent
Three controls are revealed by :hover and are the only route to their
action on a device that has none. #68 hid the home card's play button on
touch, which was right because tapping the card does the same thing;
these are the opposite case, so hiding them removes the action outright
and leaving them costs the same long-press flash #68 was filed for --
they are visibility:hidden / opacity:0, so on touch they are invisible
controls that still take taps.

track-details' cover-art overlay and remove, and shortcut-capture's
reset, are always visible under `@media not all and (hover: hover)`.

The queue row's remove is the third case the report names and takes the
other treatment, because #60 has since landed: the row's context menu is
a bottom sheet carrying "Remove from Queue", so the action is one
long-press away and an always-visible X would spend part of a 424px row
on something already reachable. It is display:none outside
`(hover: hover) and (pointer: fine)` rather than visibility:hidden,
which would leave a button holding its hit area and its place in the
accessibility tree -- the trap this issue is about.

The rule is not extracted into styles/ yet: that leaves two call sites
of the always-visible form, under the four the report names.

No tier here can render as a touch device, so the tests read the parsed
stylesheet the way #68's does and say so; the touch and hover renderings
were measured against the running app in a hasTouch context instead.

Closes #137
2026-08-21 15:59:45 +00:00
logan 60f1c5a6b2 Merge pull request 'build(frontend): fail css-check on a nested rule the phone drops' (#180) from fix/154-nested-css-check into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 9m31s
2026-08-21 15:59:25 +00:00
logan 11ba7b3180 build(frontend): sweep every stylesheet, not index.css by name
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m16s
The hook fires on frontend/**/*.{ts,css} while the script read one hardcoded path, so a second stylesheet would have been silently unswept while the hook still went green over it. There is only index.css today, which is exactly when this is cheap to fix. Watched catching a planted nested rule in a second file.
2026-08-21 15:16:47 +00:00
logan 7f8e185d7c build(frontend): fail css-check on a nested rule the phone drops
The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.

So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.

The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.

The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.

Closes #154
2026-08-21 15:16:47 +00:00
logan 42483c4b61 Merge pull request 'feat(player): show progress on the phone's bar border' (#178) from feat/58-mini-player-progress-line into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m2s
2026-08-21 15:16:26 +00:00
114 changed files with 12586 additions and 1219 deletions
+15 -2
View File
@@ -130,7 +130,13 @@ reference, because you need them *before* the failure, not after.
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice.
which its own warning had been read twice. **The same command carries
a second CSS check**: a nested rule whose selector starts with an
element name (`audio-player { … }` rather than `& audio-player { … }`)
is silently dropped by the device's Chrome 113 and by nothing else, so
every tier you can run renders it correctly. Run it after touching
`index.css` or any `css` literal; a rule directly inside a top-level
`@media` is not nested and is not flagged.
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment:
@@ -152,7 +158,7 @@ only climb when it cannot.
| You changed | Run | Cost |
|---|---|---|
| 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 |
| 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 |
@@ -174,6 +180,13 @@ less than it looks.)
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
anything in `frontend/src`, verify it in the real app too — start it
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
@@ -8,13 +8,29 @@ This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Three facts that make failure invisible
## Two facts that make failure invisible
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
There were three. The first was that **Go's stdout does not reach
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
`slog` line the app wrote was discarded, including the one naming the
error it was about to exit on. That is fixed (#160):
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
selected in `main()` by build tag, and the app's whole diagnostic
stream now arrives under the `yellowjacket` tag, which `make
android-logs` filters for.
What remains true about it is the part that misleads: **`setprop
log.redirect-stdio true` still does not help**, because it redirects
the *Java* runtime's `System.out` and the Go code is a c-shared native
library. Nothing that reaches logcat here does so through stdout, so
anything printed with `fmt.Println` is still lost. Log with `slog`.
The tag is a fixed string rather than the application id, and that is
load-bearing rather than tidy: the debug build carries
`applicationIdSuffix ".dev"` so it can be installed beside the release
app, and it is the only build whose WebView can be inspected — so a tag
derived from the id would be filtered out on the one build anybody
debugging this app is running.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
@@ -34,7 +50,10 @@ the wrong question. `make android-smoke` asks the right one — is it the
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths.
`main()` left. Work backwards through its `os.Exit(1)` paths — and
since #160, **read the `E/yellowjacket` line above it first**, because
every one of those paths logs the error before it exits. That line is
what #52 spent months without.
## What to run
@@ -524,6 +543,61 @@ Four things about it, each of which costs an hour if met cold:
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it.
### Getting the app into a state worth measuring
A fresh install is **not** a neutral starting point, and three things
about it will each cost you a measurement.
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI and is *real* here, so the app spends its
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
screen** while it does. Every vertical number taken in that state is
wrong -- one #51 measurement had the album art at 0px and it was
entirely this.
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
cleanly. **It then starts again within seconds.** So stop it
*immediately before* the measurement rather than once at the beginning,
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
that otherwise keep the band open.
**A library added over the bridge does not dismiss the first-run
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
checks for an existing library once, on mount, and its "Get Started"
button gates on a directory chosen *in the wizard* -- so it stays up
with a correctly disabled button over everything you are trying to
measure. Nothing is broken; reload the page and it is gone. This reads
exactly like a tap being swallowed, which is the expensive part.
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
`AddLibrary` takes the plain path; a push into
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
there. Some builds additionally want
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
the app opens the *system* "All files access" screen on launch -- so
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
that, not a crash.
### A note on quoting `make android-eval`
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
snippet containing a string literal -- breaks in a way that reads as a
JavaScript error. Put the expression in a file and pass it positionally:
```bash
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
```
That is the same script `make android-eval` wraps, so nothing is lost.
Two things worth knowing about it: it does **not** await a promise, so
an async call has to park its result (`window.__r = ...`) and be read
back in a second eval; and the shim from the section below is lost on
every reload and every app restart, along with the devtools socket,
whose name carries the pid.
### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth
@@ -78,9 +78,58 @@ synchronously.
Microtasks and not a timer, deliberately: a timer hangs forever under
the suites that install fake ones.
Visual baselines are font-hinting and compositing sensitive, which is
why they are opt-in: they only mean anything on the machine that
recorded them.
## The visual tier does not gate, and that is measured (#196)
`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
+293
View File
@@ -4785,3 +4785,296 @@ broken build. The specs assert the *mechanism* — that the surface is a
native `<dialog>` at phone width — which is the same move
`queue-as-a-screen.spec.ts` makes about containment and for the same
reason.
## Now Playing was not drawing a small square, it was drawing a crop (measured 2026-08-21, TLP301 / Chrome 113 / 424x439)
#172 handed #51 a design question — whether the album art gets a floor
with the block scrolling, or whether the screen reflows below some
height. Measuring it first turned up a defect underneath the question,
and the defect is bigger than the phone.
**The art was never square.** `aspect-ratio` is specified not to
re-derive the width when `max-height` clamps the height — unlike an
intrinsic ratio, which CSS2.1 10.4 preserves under both bounds. So
`width: min(100%, 60vh)` made the width definite, the ratio derived a
height from it, `max-height: 100%` clipped that height, and the width
stayed where it was. `object-fit: cover` then cropped a square cover
into the band. On the device: **264x53**, a 5:1 strip. #172's own table
called it "39px of art" and the missing half is that those 39px were
263 wide.
**And it is not only the phone.** The leftover exceeds the width only
above ~843px of viewport, so every height from ~500 to ~843 drew a
crop too — most phones, and any short window. The e2e spec written for
this fails on the old build at 424x439 (263x39), 390x700 (358x315) and
900x500 (300x36), and *passes* at 412x869, which is the boundary
falling exactly where the arithmetic says it should.
**Both maxes with auto sizes is the whole fix**, and it was chosen by
asking Chrome 113 rather than by reasoning: a probe shadow root at
column heights of 288, 300, 451, 600 and 800 measured four candidate
rules. `max-width/max-height: 100%` with `width/height: auto` is square
at all five; the shipped rule cropped at four; `aspect-ratio` on the
box cropped at the tallest. A corollary that makes it free: `auto` will
not upscale past the natural size, and the largest tier `saveCoverArt`
keeps is 400px, so nothing is lost by never exceeding it.
**The placeholder cannot use that rule and needed its own**, which is
the part that would have shipped broken. It is not a replaced element,
so with no intrinsic size auto/auto collapses it to its icon —
measured at **13x58**, neither square nor the art's size. Three things
about the rule it did get:
- It is driven from the **height**, which is the axis that binds
everywhere this view is reached from.
- A flex item's automatic minimum is its content, so without
`min-width: 0` the icon's own width becomes a floor and the box goes
wider than it is tall the moment the row is shorter than the icon —
which is exactly the state a job band puts this screen in.
- **A non-replaced box cannot express "the largest square that fits" at
all**, because whichever max clamps does not re-derive the other. The
height-driven rule alone went **380x484** at 412x869 — a tall phone,
#51's other named device — and `max-height: calc(100vw - 2rem)` is
what closes it. That is sound here for the reason `60vh` was not: this
is a phone-width detail view, so its content box really is the
viewport less the host's own gutters, and it is a *max*, so if that
ever stopped being true the failure is a square bounded early rather
than a crop. `rem` and not `em` — the box sets `font-size: 3rem` for
the icon, so `2em` there is 96px.
**Then the design question, and the reflow is the answer.** The
stacked layout's budget is fixed — 48px of header, 143px of transport
since #64, 78px of names, 68px of padding and gaps — so the art gets
`height - 386`, which is 53px at 439. A floor on the art scrolls the
transport off the bottom, and "controls never scroll off" is #51's own
Direction and plan 018's promise. So below 500px the art and the names
share a row, where the art is bounded by the row's height rather than
by the column's leftover: **53px to 143px on the device**, measured on
the shipped build, with nothing scrolling and the transport untouched.
**500 is where the two layouts cross, not a round number.** In a row
the art is `height - 296` and the names get what is left of 392px, so
the names hold 176px at exactly 500 and less above it; stacked, the art
is `height - 386`, which passes 176px at 562. It is keyed on height
alone rather than on the phone's width because it is an answer to
vertical room — a 900x450 window has the same problem and the same fix.
Two things the audit found that are *not* this, and are filed:
**#186**, every control that is not the transport is under the 44px
floor (the sort direction arrow is 28x21, and `search-trigger` — which
exists only on a phone — is 40x40), and **#187**, the seek bar's drag
target is 6px tall.
**What the audit did not find is a reachability failure**, which is
worth recording because it is the promise plan 018 makes. At 424x439,
on every view -- the ten primary ones, the queue, `album-details`,
`artist-details`, Downloads and Autotag -- `documentElement.scrollWidth`
is 424 against a 424 viewport, no control sits outside a scrollable
ancestor, and a hit test at each control's centre reaches the control.
The width work of #57, #62, #55 and #59 holds; what was left was
vertical, and it was this screen.
**A number measured on the device is not a number CI can assert.** The
spec's floor on the art's height passed here at 114 and failed in CI at
**64**, and both are honest: this app is long-lived, so a job staged by
an earlier spec is still on screen, and `volume-control` renders in a
browser where it does not on Android. Both are chrome above and below
the view and both move the leftover. That is the same trap the entry
above about staged jobs describes, arriving as a *measurement* rather
than as a stuck job. The assertion is the mechanism now -- in a row the
art fills the row's height rather than being the leftover -- and the
53-to-143 stays on the issue, where it was measured.
The probe is worth keeping in mind for the next audit, because two of
its three checks needed a second pass to mean anything. "Painted
outside the viewport" flags a horizontally scrolling carousel -- the
home shelves -- so the real question is whether a *scrollable ancestor*
can bring the element back. And a hit test at a control's centre flags
everything below the fold in a scroll container, so it only says
something once the control is on screen. Both first drafts produced
long lists of nothing.
## Two traps that cost time on the device, both already written down (2026-08-21)
Recorded because both are in `android-tier.md` and I met them anyway.
**A fresh install downloads the real catalog**, so `job-band` is 103px
of a 439px screen and every vertical measurement is wrong. Worse, it
**restarts**: `explore.Service.StopIndexBuild` returns cleanly and the
job is `running` again within seconds, so it has to be stopped again
immediately before a measurement rather than once at the start.
`YJ_CORE_INDEX_URL` is stubbed in `dev-headless.sh` and in CI and is
real on a device.
**The first-run wizard does not re-check for a library it did not
create.** Adding one through `library.Library.AddLibrary` over the
bridge leaves the wizard up with its "Get Started" button correctly
disabled — it gates on a directory chosen *in the wizard*, and the
existing-library check runs once, on mount. A reload clears it. Nothing
is broken; it cost twenty minutes of believing a tap had been swallowed.
## The sheet's scroll fade, and where a scrim may not go (measured 2026-08-23, headless)
#207's answer. The affordance is two background layers on
`wa-dialog::part(body)` and the conditionality is
`background-attachment`, not a scroll listener: a cover of the sheet's
own colour painted at the end of the *content* (`local`) over a shadow
pinned to the box (`scroll`), so the cover scrolls up and hides the
shadow exactly when there is nothing more to see.
Measured at 424x360 (which is where a menu overflows on `main`, since
`main` does not yet carry #67's eighth item — at 424x439 the track
list's seven items are `scrollHeight` 364 against `clientHeight` 364,
fitting exactly). Pixel at x=300, dark ramp, `bgElevated` `#343a40`:
| y | before | more below | at the end of the list |
|---|---|---|---|
| 330 | 52,58,64 | 50,56,62 | 52,58,64 |
| 340 | 52,58,64 | 43,48,53 | 52,58,64 |
| 350 | 52,58,64 | 33,37,40 | 52,58,64 |
| 359 | 52,58,64 | 22,24,27 | 52,58,64 |
Three things worth keeping.
**A menu that fits draws nothing**, which is the same measurement: at
424x439 the sheet is flat 52,58,64 to its bottom edge, because with no
overflow the `local` layer's positioning area *is* the padding box and
the cover lands on top of the shadow.
**A scrim over a menu row is that row's text surface**, so the 4.5:1
rule reaches it and this is why the curve is steep rather than linear.
A row is 48px with its label centred; 32px of scrim already down to a
quarter strength at 14px puts about 0.06 at the label. Checked on the
light ramp (`bgElevated` `#e9ecef`, text `#212529`) by overriding the
two custom properties on `:root`: background at the label 205,207,210,
which is **9.9:1**. The first draft — a linear 48px at 0.8 — put ~0.375
on that label, 5.0:1, passing but visibly greyed. The bottom few pixels
go to ~2.4:1 in either draft and are deliberately below where any
label of a *fully visible* row sits; a label that lands there belongs
to the half-cut row, which is the thing being signalled.
**A dark scrim on a dark surface reads far worse in a shrunk screenshot
than on screen.** The first two probes (24px/0.45, then 32px/0.75) were
measurably present — 52,58,64 down to 30,33,37 — and invisible in the
inline preview. Crop the bottom 70px and scale it up before judging;
the pixel values are the honest answer either way.
## The phone's context sheet is now longer than the phone (measured 2026-08-23, headless at 424x439)
#67 moves two destinations into every row menu, and the track list's
menu is where that runs out of screen. Measured against the running
app at the reference viewport, one row selected:
| menu | items | first item top | last item bottom |
|---|---|---|---|
| queue panel | 7 | 95 | 431 |
| track list | 8 | 86 | **470** |
The viewport is 439. So the track list's last item — "Remove from
Library" — is below the fold. It is **not unreachable**: the sheet is a
`wa-dialog` whose body is `overflow-y: auto`, measured `scrollHeight`
412 against `clientHeight` 373, and scrolling it 39px brings that item
fully into view (383431). What it has is no *affordance*: nothing on
screen says the list continues.
Two things worth knowing before adding a ninth item anywhere.
**The limit was already reached, and this is what crossed it.** Seven
48px rows in a 373px body is 364px — the queue's menu fits with 8px to
spare and the track list's fitted exactly. Any item added to any of the
fourteen menus after #60 was going to be the one that overflowed; the
first one simply happened to be this.
**The measurement has to be taken with a row selected**, since the
`Go to` items are drawn for a single selection only, and on the *first*
track of the fixture library — which has no album (`01 Tone A`,
`02 Tone B`) — only "Go to Artist" appears. That is the 8 above; an
ordinary track makes it 9.
Filed as its own issue rather than fixed in #67's diff: it is a
property of the shared sheet (`components/menu-surface/`), not of the
items.
## The tap highlight is one inherited declaration (measured 2026-08-24)
`-webkit-tap-highlight-color` is an **inherited** property, and an
inherited property crosses a shadow boundary — so `html { … :
transparent }` in `index.css` reaches every shadow root in the app and
no component needs a rule of its own. Measured in the running app
(Chromium, `app-sidebar`'s `li button`, which is three shadow roots
from the document): `rgba(0, 0, 0, 0)` with the rule, and
`rgba(0, 0, 0, 0.18)` with it removed. That 0.18 grey over the bounding
rect of whatever was tapped is what #54 reported.
The same argument was already spent once and is worth not
re-deriving: `index.css`'s first rule is `*, *::before, *::after {
user-select: none }`, which for the same reason already covers the
shadow roots — #54's Findings ask for `user-select` on interactive
surfaces and it has been done since before the issue was filed.
**What the highlight was, on the surfaces that had nothing else, is the
press feedback.** Measured on a track row with the press rule removed
and the button held down: `rgba(255, 255, 255, 0.05)` — the *hover*
tint, arriving because the pointer is over the row, which is a
synthesised hover on a phone and outlives the press. With the rule:
0.12 while held, and the neighbouring row unchanged. So the press state
is part of removing the highlight rather than a separate polish item,
and the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`, which is #68's gate applied to a
tint rather than to a revealed control.
**`touch-action: manipulation` was considered and not taken.** The
Findings offer it for the 300ms tap delay; this app's viewport is
`width=device-width`, which is what removes that delay in Chrome, so
the stated benefit is not there to win. What it would change is the
gesture stack #63 tuned by measurement on the device (`pan-y` plus a
non-passive `preventDefault`), and that is not measurable from here.
## Art pop-in is measurable in a browser, if you count frames rather than milliseconds (measured 2026-08-24)
#65 is an Android report ("scrolling through albums, the art pops in")
and the desktop harness can measure it, which was not obvious: the
first attempt waited 220 ms after each scroll jump and found **zero**
blank covers on either build. The metric only discriminates at one and
two animation frames after the jump, which is where a pop-in actually
lives.
Protocol, on `make dev-headless SEED=bulk` (4 988 albums), ten
2 400px jumps of `.grid-scroll-container`, counting covers whose rect
intersects the viewport with `naturalWidth === 0`:
| build | blank at frame 1 | at frame 2 | at 50 ms |
|---|---|---|---|
| `main` | 254 / 258 | 214 / 258 | 0 |
| `main`, second run | 254 / 258 | 190 / 258 | 0 |
| prefetch | 117 / 258 | 77 / 258 | 0 |
| prefetch, second run | 118 / 258 | 96 / 258 | 0 |
Two things this protocol gets wrong if repeated carelessly. **A second
run in the same browser session measures the HTTP cache**, not the
build — the skill already warns about this for `make perf`, and it
applies to any image measurement; every row above is a fresh
`playwright-cli close` + `open`. And **the frontend is embedded**, so
comparing builds is a `git stash` *and* a rebuild, not a stash.
**The bulk library's covers are 300x300 and ~3.7 kB**, which is why
both builds are clean by 50 ms here and why the phone's number cannot
be inferred from this one — same caveat the skill already records
about full-size artwork.
**`rangeChanged` and `visibilityChanged` are different ranges**, and
the difference is the whole of this fix's value.
`@lit-labs/virtualizer` reports `_first`/`_last` (rendered, including
the ~1000px overhang) on the former and `_firstVisible`/`_lastVisible`
on the latter. Both grids listen to `visibilityChanged` for scroll
persistence, which wants the visible range and is correct; a prefetch
window measured from it lands mostly on cards that already exist.
Anchored there, the component test could see only one row past the
last rendered card.
**`_overhang` is not configurable.** It is a `protected` field set to
1000 in `BaseLayout` and read by every layout; there is no option on
`grid()`/`flow()` and no property on the element. The issue's Direction
("ask the virtualizer for a larger overscan") is therefore not
available without patching a private, which is why the request is
issued ahead of the element instead.
@@ -0,0 +1,408 @@
# 019 — The Android touch model
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
being diverged from)
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
#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
for the interaction model to be designed as one piece before any of it
is built, because it *reassigns an existing gesture* rather than adding
one — `utils/long-press.ts` currently owns the 500ms hold, and every
context menu in the app is downstream of it.
This document is that design. Everything below is a measurement, or an
argument for one of the choices #63 leaves open.
---
## The mapping
| gesture | pointer is a finger | pointer is a mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press (500ms) | **enter selection mode** | — |
| right-click | — | context menu |
| swipe right | **add to queue** | — |
| drag | reorder / drag to playlist | reorder / drag to playlist |
Three of those are #63's report unchanged. Two are decisions it left
open, and one is a deliberate divergence.
---
## Decision 1 — the predicate is the pointer, not the platform
#63 says "the row component needs a platform-aware interaction layer
rather than shared handlers". It needs an interaction layer; it should
not be platform-aware.
**The question a row has to answer is not "am I on Android" or "is the
viewport under 600px" but "what made this event".** `pointerType ===
'touch'`, read off the event that is being handled, which is already
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
and is the only such test in the frontend today.
This is #64's rule — the predicate is named after the capability, not
the platform — and it carries #64's warning with it. Keyed on a width:
- an Android **tablet** at 600px or more gets click-selects /
double-click-plays on a touchscreen, which is the exact inversion
this issue exists to fix, on the platform it exists for;
- a **touchscreen laptop** cannot be described at all, because both
pointers are live in the same session on the same row;
- and a narrow desktop window gets phone semantics with a mouse.
Per event, all three are right for free, and there is no second
declaration of what a phone does — the thing CLAUDE.md declines to add
every time it comes up.
**Measured, so this is not an assumption about the WebView.** On the
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
driving a real tap with `adb shell input tap`:
```
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
```
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
5, and `(pointer: coarse)` / `(hover: none)` both match.
---
## Decision 2 — there is no double-tap, and the number is why
#63 asks for *single tap → play* **and** *double tap → context menu*.
Those two cannot both be honoured. The first tap of a double tap is
indistinguishable from a single tap until the interval expires, so
"tap plays" necessarily becomes "tap waits to find out whether you
meant something else, then plays". The app already owns that constant:
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
= 250` for precisely this reason.
**What it would be added to, measured on the device.** Six runs, from
the play command to the backend's `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
So the app's primary interaction is ~100ms, and a double-tap
discriminator makes it ~350 — **3.5x, of which 250ms is spent
deliberately doing nothing** — paid on every track anyone ever plays,
in order to reach a menu.
It is also against the platform's convention, which counts for more
than usual here because this is the phone build and nothing else:
long-press is *how you select* on Android (Gmail, Files, Photos),
double-tap is zoom or nothing, and a list's menu is either the
long-press sheet or a per-row overflow.
**So the menu and the selection action bar become the same surface**,
which is the convention and removes a concept rather than adding one.
Long-press selects the row it was made on and raises the action bar;
the bar's actions *are* the context menu's actions, contextualised to
whatever is selected — one row or forty. #60's bottom sheet stays
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
`menu-surface` are reused rather than reimplemented.
---
## Decision 3 — tap-to-play and selection mode ship together
The obvious phase order is "tap plays first, it is the smallest
change". It is wrong, and the reason is a capability that exists today
and is easy to miss.
**A touch user can already multi-select**: tap selects (the desktop
semantics, which a finger currently gets), and the long-press menu then
acts on the selection. Move tap to play without shipping selection mode
in the same change and there is a window — a release, if it lands — in
which selecting forty tracks to add to a playlist is impossible on a
phone. That is a regression dressed as an increment.
So phase 1 is both, or neither.
---
## What the code looks like now
| surface | how it binds | selection |
|---|---|---|
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
| `queue-panel` | delegated, same shape | `SelectionController` |
| `playlist-details` | per row | `SelectionController` |
| `smart-playlist-details` | per row | `SelectionController` |
All four already share `SelectionController`, and all four resolve a
row from an event by `data-index` / `data-file-path` on the row. So the
gesture layer has one shape to talk to, and "selection mode" is a flag
on the controller they already have rather than a fifth concept.
`utils/long-press.ts` is one document-capture listener that synthesises
a `contextmenu` — the seam that needed no component to opt in. **This
plan keeps that shape and changes what the gesture means**, which is
why it is a rewrite of that file rather than a second listener set: two
document listeners both claiming the 500ms hold is the fault the file's
own header warns about.
---
## Two measurements that decide the implementation
**`touch-action` is `auto` on both the virtualizer and the rows.** With
`auto` the browser owns panning on both axes, so a horizontal drag can
be claimed as a scroll and our gesture ends in `pointercancel`
mid-swipe. A row that wants a horizontal swipe has to declare
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
virtualizer's scroll, and must stay native or the list stutters) and
hands us the horizontal axis. This is the single most likely way for
swipe-to-queue to "work in Chromium and not on the phone".
**The row is 424x52 on the device**, so a swipe threshold in px is a
fraction of a row height, not of a screen.
**And the third one was found by building phase 1 and then running it**
— it is not something any browser tier can report. Chrome 113's Android
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
stood down when a trusted one arrived, which was right while both paths
ended in the same place; once a hold can mean selection mode they end
in different places, and standing down means the gesture silently does
the *old* thing. Measured, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced at all, the context menu opened,
and all 26 tests in the component tier passed — dispatched pointer
events do not make a browser synthesise a `contextmenu`.
So the browser's event is a **trigger, not a competitor**: the gesture
is announced from it, and only a component that claims it suppresses
the native menu. Unclaimed, it propagates untouched. That is the same
"browser wins" outcome, reached by asking instead of assuming — and
verified both ways on the device, a track row entering selection mode
and an album card still opening its menu.
The tier could not *find* it and can *hold* it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than `isTrusted`, so an untrusted one from a test takes
exactly the browser's path.
---
## A tier note: this one can be driven, not only measured
`adb shell input tap|swipe` reaches the WebView as real pointer events,
which the log above is evidence of. So for the first time the Android
tier can *perform* the thing under test rather than describe the page
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
is `input swipe X Y X+N Y 120`.
Device CSS pixels from device pixels, on this phone:
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
horizontally (measured from the tap above: 200,300 arrived as 78,94).
This does not make the device a spec tier — it does not run in CI and
`make ui-test` still has to carry the assertions. It makes "does the
gesture actually fire on Chrome 113" answerable in seconds.
---
## Phases
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
recognition of tap / long-press / horizontal swipe, dispatched as
composed custom events so a delegated listener in any shadow root
still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
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
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
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
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`
5070ms *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
1. **Does selection mode have an escape other than the bar's own
close?** *Settled: Escape, here; back, not here.*
Escape leaves the mode, from `selection-bar` rather than from each
of the four hosts — that 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 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
mode?** *Settled in phase 1: yes.* A control inside the row keeps
its own tap — the gesture is simply not claimed there, so the click
behind it falls through untouched. It is the same rule the shortcut
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.
+477 -24
View File
@@ -262,6 +262,26 @@ real store code. A binding carries an **ID**, not a name
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
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
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)
@@ -1093,9 +1113,9 @@ not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" drawer
after a navigation still has to know.** `bottom-nav`'s "More" sheet
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, the drawer opened highlighting
`navigate` at all — standing on Albums, it opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
@@ -1196,7 +1216,7 @@ and then vanishing.
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
for "what a phone shows is a different question", and it would apply —
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
which filters, so an unfiltered bar would contradict its own sheet one
tap away. Which four tabs is still plan 016's committed subset; this
only removes from it, and "More" is never filtered because it is how
everything else stays reachable.
@@ -1402,7 +1422,7 @@ descendants. On the reference device the main panel spans 0-318 of a
items cut off, with no way to reach them. `showModal()` is Chrome 37
and uses the real top layer, so a dialog is immune by construction.
Six things about it are load-bearing.
Seven things about it are load-bearing.
**"Dialogs are fine" needed checking, because every other dialog in
this app is mounted in `index.html`** — outside `.main-panel` — so it
@@ -1431,6 +1451,25 @@ doing nothing, which reads as the gesture breaking. `menu-dismiss` is
that signal; the three surfaces that do not use `ContextMenuController`
bind it themselves.
**A sheet that scrolls says so, and `background-attachment` is what
asks whether it does** (#207). The sheet is capped at 85vh — a surface
covering the whole screen is a page, not a sheet — so a long menu's
body scrolls, and for three phases it scrolled *silently*: measured at
424x439, eight items ended at y=470 with the fold at 439, and where the
cut lands on a row boundary the sheet ends in a clean edge that reads
as the end of the list. The fade is two background layers on
`wa-dialog::part(body)` — a shadow pinned to the box (`scroll`) under a
cover of the sheet's own colour painted at the end of the *content*
(`local`), which scrolls up over the shadow exactly when there is
nothing more to see. So it is absent on a menu that fits, present the
moment one does not, and gone again at the end of the list, with no
scroll listener and nothing reaching into `wa-dialog`'s shadow root for
the scroller. **The curve is steep because the rows under it stay
live**: a scrim over a menu item is that item's text surface, and the
4.5:1 rule applies to it — 32px already down to a quarter strength at
14px spends its weight below the last legible label, measured at 9.9:1
on the light ramp, whose `bgElevated` is `#e9ecef`.
**The playlist submenu is a sheet too, and it had to be.** It is a
`placement="right-start"` flyout, and making the menu full-width moved
its anchor — measured at x 182 to 0, entirely off-screen, so "Add to
@@ -1449,18 +1488,193 @@ never opens). **The sweep found two of the fourteen**; twelve were
converted by hand.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`utils/touch-gestures.ts` is one document-capture listener installed
once from `index.ts``utils/long-press.ts` until #63 replaced it,
rather than adding a second listener claiming the same 500 ms hold. It
**announces** rather than acts: `yj-tap`, `yj-long-press` and
`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
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
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.
reaches a delegated listener and no per-row one; and the click that
ends a *claimed* 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 **5070 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,
and which way round depends on whether it is the only route to its
action.** The gate itself is not optional: a touch long-press
synthesises a hover state in the WebView, so every one of these flashed
into view during the 500 ms hold above — a control appearing because
the user was reaching for a different one. Where the action is reachable
another way the control is **absent** on a touch device (the home card's
play button, #68; the queue row's remove, which the row's bottom-sheet
menu carries since #60), and that is `display: none` outside
`(hover: hover) and (pointer: fine)` rather than `opacity: 0` or
`visibility: hidden`, both of which leave a button holding its hit area
and its place in the accessibility tree. Where the control is the
**only** route it is instead always visible under
`@media not all and (hover: hover)``track-details`'s cover-art
overlay and remove, `shortcut-capture`'s reset (#137) — because hiding
it takes the action away entirely.
**Always-visible is not the same as always-in-the-way.** The cover-art
overlay is `inset: 0` at 50% black, which is fine as a hover state and
is not fine as the permanent appearance of the artwork being edited —
and it is only a *hint*, since `.cover-art-edit` carries the click and
tapping the art always worked. Off hover it becomes a corner chip in
the remove button's own language. The × beside it stays full-size,
because that one really is the only route to its action.
One thing to know before checking either: **no *committed* tier renders
as a touch device.** CDP's `Emulation.setEmulatedMedia` does not reach
the component tier's iframe, and the e2e projects are Desktop Chrome
and Desktop Safari, neither of which has touch — a Playwright project
using a mobile descriptor would report `hover: none`, so this is a
choice not to carry one rather than a thing that cannot be done. So
`hover-affordance.test.ts` asserts the *parsed stylesheet* — which rule
sits inside which media query — and says so; the regression it exists
for is someone hoisting a rule out of its query as a tidy-up, which
nothing on a desktop renders differently.
**The web view's own tap highlight is gone, and what replaced it is a
press state** (#54). `-webkit-tap-highlight-color` is an *inherited*
property, so one declaration on `html` in `index.css` reaches every
shadow root in the app and takes away the grey box a phone drew over
the bounding rect of whatever was tapped — measured at
`rgba(0, 0, 0, 0.18)` with the rule removed. `user-select` is the same
argument and was already done: `index.css`'s first rule is `*, *::before,
*::after { user-select: none }`, which reaches the shadow roots for the
same reason.
Three things about it are load-bearing.
**Removing the highlight removes the only touch feedback several
surfaces had**, so the press state is part of the same change rather
than a later polish item: the four lists' rows, `bottom-nav`'s tabs,
`app-sidebar`'s destinations (which are also the phone's "More" sheet)
and the shared `contextMenuStyles` menu item all take
`--yj-press-overlay` on `:active`. The cards already had one
(`transform: scale(0.97)`) and are untouched.
**A press selector carries a state class or it does nothing where it
matters.** A row is `.track-row.selected.active`, so a bare
`.track-row:active` is one class short of it and the press is invisible
on exactly the row a phone is most likely to press — the one it has
just selected. The rule is last and lists `.selected:active` /
`.active:active` beside the bare form.
**And the hover tints on those same surfaces moved behind
`(hover: hover) and (pointer: fine)`**, which is #68's gate applied to
a tint rather than to a revealed control and for the same mechanism: a
hold synthesises a hover in the WebView, so an ungated tint arrives
because a finger touched the row and stays there after it has gone —
measured, since with the press rule removed a held row reads
`rgba(255, 255, 255, 0.05)`, the hover tint, rather than nothing.
`touch-action: manipulation` was considered and declined: the 300ms
delay it is offered for is already absent on a `width=device-width`
viewport, and what it would really change is the gesture stack #63
tuned by measurement on a device this session cannot measure.
The split of tiers is `hover-affordance.test.ts`'s: `press-feedback.
test.ts` reads the parsed stylesheet, because `:active` cannot be
forced there either, and `native-touch-feel.spec.ts` *measures* — it
holds the button down on a real row of the real list, and it is the
only tier that loads `index.css` at all.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
@@ -1618,6 +1832,21 @@ is not it.** A `placeholder` is an accname fallback, so an
Explore's search box — the audit's own `a11y.26` — as clean. A sweep
for *empty* names cannot see a *weak* one.
**`title` is the same trap one rung lower, and it defeats the obvious
spec as well as the obvious sweep.** `queue-panel`'s Clear queue and
Add queue to playlist were named by `title` alone, so
`getByRole('button', { name: 'Clear queue' })` matched them **before**
the fix as well as after — a `getByRole` assertion, which is what
catches every other nameless control in this app, would have been
green on the broken build. `title` is the *last* fallback in the
accname order, so content put inside the button later silently
outranks it, and it is the one name a phone cannot show, having no
hover. The property is therefore asserted as *the name is not the
tooltip*: `queue-overlay.spec.ts` removes the `title` attributes and
asks again, which is 1 and 1 with `aria-label` and was measured at 0
and 0 without it. The `title`s stay, because on a desktop they are
also the tooltip for an icon-only control and that is a different job.
**The shell scrolls sideways and not down.** `body` is
`overflow-x: auto; overflow-y: hidden`, and both halves are measured.
Vertically there is nothing to fix: the middle grid row is `1fr` and
@@ -1660,7 +1889,7 @@ listing the destinations again — but rendering it unconditionally put a
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
specs failed with "strict mode violation: resolved to 2 elements" on a
desktop viewport where the element is not even visible. It renders only
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
while the sheet is open, and `bottom-nav.test.ts` asserts its absence
before that.
**The tab bar is four destinations and a way to the rest.** Three to
@@ -1669,6 +1898,33 @@ is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**And "More" rises from the bottom, on #60's sheet rather than a
second one** (#71). It was a `wa-drawer placement="start"`: a 200px
column of a 424px screen, opening away from the thumb that asked for
it, with the rest of its 400px band empty. It is the *same element*
with `placement="bottom"` and `without-header`, which is what keeps
the change to where it comes from — `wa-drawer` renders a native
`<dialog>` and opens it with `showModal()`, so #60's containment
finding carries over with nothing new to prove, and the focus trap,
Escape, tap-outside and `wa-after-hide` all come along. Measured at
424x439: 424 wide, 373 tall (85vh, so there is an outside to tap),
48px rows.
Three things about it are load-bearing. **The sidebar is mounted
rather than re-listed as data**, which the issue offers as the
alternative: the shell's own `<app-sidebar>` is `display: none` below
600px rather than removed, so a second list drawing `nav-*` handles is
the duplication above, and it would be a second place to add the next
view to. **There is one scroller, and it is the sheet's body** — the
reported "only part of the screen scrolls under my finger" is three
nested ones (the dialog, its body, and the sidebar's own
`overflow-y: auto` host), so which box a drag moves depends on where
the finger landed; `overscroll-behavior: contain` is the other half.
And **`expanded` means the host owns the box, not just the labels**:
`app-sidebar` writes an *inline* width and caps itself at 400px, which
beats any rule the host could write, so the width, the scrolling and
the mouse-only resize handle all follow that attribute.
**There are three supported size bands, and the queue is part of the
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
nav, reflows, fits 320px exactly), **Compact** 600899 (icon sidebar),
@@ -2033,9 +2289,26 @@ one that closes the queue — the reported defect moved one press later,
which looks exactly like a press that did nothing.
**And the way out is 44px on a phone.** With the panel spanning the
whole width the scrim has no uncovered pixels at all, so the close
button is the only pointer route out of a full-screen surface; it was
**25×21px**.
whole width there is no scrim there at all, so the close button is the
only pointer route out of a full-screen surface; it was **25×21px**.
**The scrim is drawn only where it can be tapped** (#171). Below 600px
`.panel-content` is `width: 100%`, so the scrim sat entirely underneath
an opaque panel — measured at 424×439, host, panel and scrim all
424×318 — dimming nothing and dismissing nothing while wearing
`cursor: pointer`. #24's tap-outside-to-close cannot exist on a surface
with no outside, and the screen above is what answers it instead: back,
and a 44px close button. The alternative — a gutter, which is the
drawer pattern — was declined, because it buys the affordance by taking
width off a full-screen surface on a 424px viewport. Two things about
it are load-bearing. Its **existence** is `matchMedia`, not
`display: none`, on `job-band`'s rule: a hidden scrim is still an
element carrying the dismissal handler. And **the 600899 band is
untouched**, where the panel is a 320px column of a wider content area
and the scrim has real uncovered pixels — which is why the e2e half
asserts *absence* at 424×439 rather than clicking, since a phone-width
case that clicks the scrim's centre hits the panel and passes on the
broken build.
What this does **not** fix is `page-header` overflowing on its own:
at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with
@@ -2067,6 +2340,54 @@ toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**And below 500px of height its art and its names share a row** (#51).
The stacked arrangement's budget is fixed — 48px of header, 143px of
transport since #64, 78px of names, 68px of padding and gaps — so the
art gets `height - 386`, which at the reference device's 424x439 is
**53px**: the one thing a Now Playing screen exists to show, smallest
on it. #172 named the two ways out and this is the second, because the
first — a floor on the art with the block scrolling — scrolls the
transport off the bottom, and *controls never scroll off* is #51's own
Direction and plan 018's promise. Sideways the art is bounded by the
row's height instead of by the column's leftover: **53px to 143px**,
measured on the device, nothing scrolling, the transport untouched.
Three things about it are load-bearing.
**500 is where the two layouts cross rather than a round number.** In a
row the art is `height - 296` and the names get what is left of 392px,
so the names hold 176px at exactly 500 and less above it; stacked, the
art is `height - 386`, which passes 176px at 562. Below 500 the row is
the bigger art *and* the readable one — above it the column is, which
is why a tall phone keeps the arrangement it has. It is keyed on height
alone and not on the phone's width, because it answers vertical room: a
900x450 window has the same problem and the same fix.
**The art was not a small square, it was a crop, and that was never
only the phone.** `aspect-ratio` is specified not to re-derive the
width when `max-height` clamps the height — unlike an intrinsic ratio,
which is preserved under both bounds — so a definite `width: min(100%,
60vh)` kept its width while the height was clipped, and `object-fit:
cover` cropped a square cover into the band: **264x53** on the device.
The leftover exceeds the width only above ~843px of viewport, so every
height from ~500 to ~843 drew one too. `max-width`/`max-height: 100%`
with `width`/`height: auto` is the fix and is the replaced-element
path; it also never upscales past the natural size, and the largest
tier `saveCoverArt` keeps is 400px, so nothing is lost.
**The placeholder needs its own rule, and a non-replaced box cannot
express this one.** With no intrinsic size, auto/auto collapses it to
its icon (13x58, measured). It is driven from the height instead, with
`min-width: 0` because a flex item's automatic minimum is its content —
without it the icon's width becomes a floor the moment the row is
shorter than the icon, which is exactly what a job band does to this
screen. And since whichever max clamps does not re-derive the other, a
height-driven box goes **380x484** on a tall phone; `max-height:
calc(100vw - 2rem)` closes it, which is sound here for the reason
`60vh` was not — this is a phone-width detail view, so its content box
really is the viewport less the host's gutters, and it is a *max*, so
the failure mode is a square bounded early rather than a crop.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
@@ -2141,6 +2462,31 @@ a list or a detail view:
and dropped if a second click arrives, because the title is the
widest thing in a row and double-clicking a row plays it. Rows do
not need to know links exist.
**Below 600px a name is not a link, and the row's menu is where it
went** (#67). Every sentence above is a *desktop* compromise: the
double-click grace means nothing on touch, a few characters of text
is not a touch target, and since #63 a claimed `yj-tap` has its click
swallowed, so the link was unreachable as well as fiddly. The rule is
in the utility rather than at twenty call sites, and
`utils/go-to-menu.ts` is the other half — "Go to Artist" / "Go to
Album", drawn under exactly the condition the link is not, from
`explore-link`'s own exported routing so an untagged artist reaches
the library page by the same lookup.
Three things about it are load-bearing. **Suppressing a link without
a menu behind it is not a smaller affordance**, it is a destination
the phone cannot reach — so `keepOnPhone` is the documented exception
for the three surfaces with no row menu (`now-playing-view`,
`explore-album-details`' header credit, `top-results-row`), and
nothing else may pass it. **One row or none**: the items are the Play
item's rule one step on, since "go to the album" of five different
albums means nothing. And **there is no "Go to Genre"**, because
there is no genre link anywhere to lose — that would be new
navigation rather than a replacement, and belongs in its own issue.
`track-list` is the one list that gains rather than moves: its phone
column set stacks title over artist as plain text already, so those
names have never been links there.
- **`<catalog-scope-notice>`** is how a detail page admits what it is
showing: catalog data (silent), a library stand-in while a fetch is
in flight, library-only because the entity has no MBID, or a failed/
@@ -2354,11 +2700,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
same rule `Known` exists for.
One gap this did not close, and it is older: **`dhowden/tag` has no
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk
is visible to `metadata.ExtractTags` — not the totals and not the title
either. `wav_test.go` reads that chunk itself, which is why no test
ever noticed.
One gap this did not close and #104 did: **`dhowden/tag` has no RIFF
reader**, so nothing the tag writer put in a WAV's `id3 ` chunk was
visible to `metadata.ExtractTags` — not the totals and not the title
either, on files the app itself had just tagged. `wav_test.go` read
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
put a green tick against every owned track and a legend underneath
@@ -3045,6 +3418,46 @@ rather than searching it — the store replaces that array when its
contents change and shares the unchanged members, which is the same
signal `track-list`'s memoized caches key on.
**And the right tier arriving late still reads as no art at all**, so
the two grids ask for it before the card exists (#65).
`utils/image-prefetch.ts` warms the images a scroll is about to reach,
from `cover-grid`'s and `artists-view`'s virtualizers. Measured on the
50 000-track bulk seed over ten 2 400px jumps: of 258 covers arriving
in view, **254 were still blank one frame later and 214 two frames
later**; with the prefetch, 117 and 77. Both builds are clean by 50 ms
on a desktop with 3.7 kB fixture covers, which is where the reference
device's slower engine and 27 kB covers spend their pop-in.
Four things about it are load-bearing.
**The overscan the obvious fix asks for does not exist.**
`@lit-labs/virtualizer`'s `_overhang` is a hard-coded 1000px
`protected` field on `BaseLayout` with no configuration surface, so
raising it means monkey-patching a private. 1000px is about two
screens on a 439px viewport, and the *image* cannot be requested until
the card it lives in is rendered — which is what this asks for
instead.
**It hangs off `rangeChanged`, not `visibilityChanged`.** Those report
different ranges: visibility is what is on screen, and the virtualizer
has already rendered that 1000px past it. Anchored to the visible
range the window is spent on cards that already exist and have already
asked for their own art — measured as the difference between the
prefetch reaching one row past the last card and reaching a full
window past it.
**It is not the `LRUMap` path, and saying so is the bound.** That
ceiling holds Explore's base64 data URLs in JS; a library cover is a
plain URL under `Cache-Control: immutable` (the filenames are content
hashes), so what retains the bytes is the browser's own cache. What
this module retains is the *set of URLs already asked for*, capped at
512 and reported to `window.__yjCacheStats()` — 497 entries and 15 407
chars after the run above.
**The prefetch asks for what the card will draw.** `artists-view`'s
tier ladder moved into `artistAvatarURL()` so the two cannot disagree;
a second copy would be a warm cache for a tier nothing renders.
**The same rule, on the selection path, was the worst stall in the
app.** Five components turned selected file paths back into tracks with
`filePaths.map(fp => tracks.find(…))`, so "Select all → Edit tags" at
@@ -3229,6 +3642,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
`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
Feature branches and PRs are the only way in: **`main` is a protected
@@ -3496,6 +3928,27 @@ android-inspect` forwards the WebView's devtools socket and `make
android-eval` asks the real page — raw CDP, because `connectOverCDP`
calls `Browser.setDownloadBehavior` and a WebView refuses it.
**One of those gaps is checked rather than remembered.** A nested rule
whose selector starts with an element name is not a parse error anyone
would notice on 113 — the rule simply does not exist, there and nowhere
else, which is how the bottom bar's `text-overflow: ellipsis` came to
have never truncated on the device. `make css-check`
(`frontend/scripts/check-css-nesting.mjs`, a pre-commit hook and a CI
step) fails on one, over every `frontend/*.css` and the `css` literals
alike — a glob rather than `index.css` by name, because the hook fires
on `frontend/**/*.{ts,css}` and a sweep that names one file goes green
over a stylesheet it never opened — and
says the fix is a leading `&` — valid in both syntaxes, so no nested
rule here has a reason to omit it. Two things it has to get right, and
both follow from asking whether a *style* rule is anywhere above rather
than what the immediate parent is: `@media (…) { bottom-nav { … } }` at
the top level is an ordinary rule and is the majority of what a regex
over the file would report, while the same rule one level inside
`.bar { @media (…) { … } }` is nested and is flagged. The check is the
cheap version of the answer; a build-time downlevel (Lightning CSS
targeting 113) would fix the class permanently and is a dependency and
a build step rather than twenty lines.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
+12 -4
View File
@@ -160,8 +160,11 @@ ui-watch: ## Same suite, in watch mode
ui-visual: ## Run the suite including screenshot comparisons
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
ui-visual-update: ## Re-record the screenshot baselines
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS)
# `--update=true`, never a bare `--update`: vitest takes the following
# 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)
@cd frontend && pnpm install && npx playwright install chromium
@@ -172,12 +175,17 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if the generated bindings are stale
@./scripts/bindings-check.sh
# Two CSS traps that report a long way from their cause, or not at all.
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. Instant.
# the suite failing to import. Four sessions, three plans. And a nested
# rule starting with an element name is dropped by the device's
# Chrome 113 in silence -- no tier here runs an engine that can see it.
# Instant.
.PHONY: css-check
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
@cd frontend && node scripts/check-css-literals.mjs
@cd frontend && node scripts/check-css-nesting.mjs
# .pi/ and CLAUDE.md document commands, and a doc that documents a
# command wrongly is worse than no doc: an agent runs it confidently.
+55
View File
@@ -0,0 +1,55 @@
//go:build android
// The write itself, and nothing else. Everything decidable off a phone
// is in androidlog.go; see the package comment for why.
package androidlog
/*
#cgo LDFLAGS: -llog
#include <stdlib.h>
#include <android/log.h>
*/
import "C"
import (
"log/slog"
"unsafe"
)
// The priorities in androidlog.go are android/log.h's own values, and
// these are what says so. A constant expression that would be negative
// does not compile as a uint, so a renumbered header fails the build
// here rather than logging everything at the wrong severity -- which is
// the failure that would otherwise be invisible, since logcat would
// happily print whatever number it was handed.
const (
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
)
// New returns the handler main() installs on Android.
func New(opts *slog.HandlerOptions) slog.Handler {
return NewHandler(opts, write)
}
// write hands one line to liblog.
func write(prio int, tag, msg string) {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
cMsg := C.CString(msg)
defer C.free(unsafe.Pointer(cMsg))
C.__android_log_write(C.int(prio), cTag, cMsg)
}
+250
View File
@@ -0,0 +1,250 @@
// Package androidlog routes slog to logcat.
//
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
// app writes with slog is discarded on that platform -- including the
// one naming the error it is about to os.Exit on. #52 is what that
// cost: a process that vanished with no tombstone, no AndroidRuntime
// stack and nothing in `logcat -b crash`, at Priority/Critical for
// months, whose entire diagnosis was one slog.Error main.go was
// already writing.
//
// The platform's own sink is __android_log_write, which is a handful
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
// runs, since the only toolchain that builds the android tag is a
// cross-compiler and the only thing that runs it is a phone. So the
// split here is the one backend/mediacontrols/androidpayload.go makes,
// pushed as far as it will go: **everything except the write itself is
// in this file, untagged**. The priority mapping, the formatting, the
// chunking and the handler's own attr and group bookkeeping are
// ordinary Go that `go test` exercises on any platform; android.go is
// fifteen lines that hand a string to liblog.
package androidlog
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"sync"
)
// Tag is what logcat labels these lines with.
//
// It is a constant of ours rather than the application id, because the
// debug build carries `applicationIdSuffix ".dev"` so that it can be
// installed beside the release app -- so a tag derived from the package
// name is a *different* tag on the one build that can be inspected, and
// the filter that is supposed to show these lines would hide them on
// exactly the build used to look for them.
const Tag = "yellowjacket"
// Android's priorities, from android/log.h. These are the values
// __android_log_write takes; android.go asserts at compile time that
// they still match the header, so a renumbered platform is a build
// failure here rather than a warning silently logged as an error.
const (
PrioVerbose = 2
PrioDebug = 3
PrioInfo = 4
PrioWarn = 5
PrioError = 6
PrioFatal = 7
)
// maxPayload is how much of one line liblog will carry.
//
// The kernel logger's entry is 4068 bytes for the tag, the message and
// their two NULs together, and what does not fit is **dropped without
// comment** -- so a long line would be truncated in the middle of the
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
// a continuation carries.
const maxPayload = 3500
// WriteFunc is the platform sink: one already-formatted line, at one
// priority, under one tag.
//
// It is a parameter rather than a package-level function so that the
// handler can be driven by a test on a machine with no liblog at all.
type WriteFunc func(prio int, tag, msg string)
// Priority maps a slog level onto an Android one.
//
// slog's levels are open -- a caller may define its own at any int --
// so this is a banding rather than a lookup: anything below Info is
// debug, anything at or above Error is error. A custom level between
// two of the standard ones lands in the band beneath it, which is what
// slog's own level naming does.
func Priority(level slog.Level) int {
switch {
case level < slog.LevelDebug:
return PrioVerbose
case level < slog.LevelInfo:
return PrioDebug
case level < slog.LevelWarn:
return PrioInfo
case level < slog.LevelError:
return PrioWarn
default:
return PrioError
}
}
// Handler formats records with slog's own TextHandler and hands each
// line to a WriteFunc.
//
// It delegates the formatting rather than doing it, because WithAttrs
// and WithGroup are the half of slog.Handler that is easy to get subtly
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
// What it does own is what logcat needs and TextHandler does not know
// about: the priority, and the fact that a line has a maximum length.
type Handler struct {
write WriteFunc
// mu guards buf, which the delegate writes into. slog.Handler is
// documented as safe for concurrent use.
mu *sync.Mutex
buf *bytes.Buffer
delegate slog.Handler
}
// NewHandler builds a handler over an arbitrary sink.
//
// The time and the level are dropped from the formatted line: logcat
// stamps every entry with both, and repeating them costs a quarter of
// the width of a phone-sized terminal to say the same thing twice.
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
buf := &bytes.Buffer{}
inner := &slog.HandlerOptions{}
if opts != nil {
*inner = *opts
}
user := inner.ReplaceAttr
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && isBuiltin(a) {
return slog.Attr{}
}
if user != nil {
return user(groups, a)
}
return a
}
return &Handler{
write: write,
mu: &sync.Mutex{},
buf: buf,
delegate: slog.NewTextHandler(buf, inner),
}
}
// isBuiltin reports whether an attr is slog's own time or level,
// rather than a caller's attribute that happens to share the name.
//
// ReplaceAttr cannot tell those apart by key. It is called with an
// empty group path for the built-ins *and* for every top-level
// attribute, so a key comparison alone silently eats a caller's own
// "level" or "time" -- which is not hypothetical: the probe that
// verified this package on the device logged one, and the attribute
// vanished. The kinds are what separate them, because slog builds the
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
// and an attribute value of type slog.Level is not something a caller
// passes by accident.
func isBuiltin(a slog.Attr) bool {
switch a.Key {
case slog.TimeKey:
return a.Value.Kind() == slog.KindTime
case slog.LevelKey:
_, ok := a.Value.Any().(slog.Level)
return ok
default:
return false
}
}
// Enabled reports whether the level is worth formatting.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.delegate.Enabled(ctx, level)
}
// Handle formats one record and writes it out, in as many entries as
// its length demands.
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.buf.Reset()
if err := h.delegate.Handle(ctx, rec); err != nil {
return err
}
prio := Priority(rec.Level)
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
h.write(prio, Tag, line)
}
return nil
}
// WithAttrs returns a handler carrying the given attributes.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h.derive(h.delegate.WithAttrs(attrs))
}
// WithGroup returns a handler that qualifies subsequent attributes.
func (h *Handler) WithGroup(name string) slog.Handler {
return h.derive(h.delegate.WithGroup(name))
}
// derive shares the buffer and its mutex with the parent.
//
// They must be shared rather than copied: the delegate returned by
// WithAttrs writes into the *same* buffer this one does, so a second
// mutex would guard nothing and two loggers derived from one would
// interleave their bytes into a single line.
func (h *Handler) derive(delegate slog.Handler) *Handler {
return &Handler{
write: h.write,
mu: h.mu,
buf: h.buf,
delegate: delegate,
}
}
// Chunk splits a formatted record into entries liblog will carry
// whole.
//
// A record short enough to fit is returned as it is, which is nearly
// every record; the numbering only appears where something was going
// to be silently truncated anyway. It splits on bytes rather than runes
// because the limit is a byte count -- a multi-byte rune straddling the
// boundary is a mojibake character in a log line, against a lost one.
func Chunk(msg string) []string {
if len(msg) <= maxPayload {
return []string{msg}
}
var parts []string
for rest := msg; rest != ""; {
n := min(maxPayload, len(rest))
parts = append(parts, rest[:n])
rest = rest[n:]
}
numbered := make([]string, 0, len(parts))
for i, p := range parts {
numbered = append(
numbered,
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
)
}
return numbered
}
+355
View File
@@ -0,0 +1,355 @@
package androidlog_test
import (
"log/slog"
"strings"
"sync"
"testing"
"yellowjacket/backend/androidlog"
)
// entry is one call to the sink.
type entry struct {
prio int
tag string
msg string
}
// recorder is the platform write, on a machine with no platform.
type recorder struct {
mu sync.Mutex
entries []entry
}
func (r *recorder) write(prio int, tag, msg string) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
}
func (r *recorder) only(t *testing.T) entry {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) != 1 {
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
}
return r.entries[0]
}
func newLogger(r *recorder, level slog.Level) *slog.Logger {
return slog.New(androidlog.NewHandler(
&slog.HandlerOptions{Level: level},
r.write,
))
}
// TestPriorityMapsEveryLevel pins the level banding.
//
// This is the one thing in #160 that a wrong answer hides rather than
// breaks: logcat prints whatever priority it is handed, so an Error
// filed as Info is a line that is present, correct and invisible to
// every filter anyone would use to look for it.
func TestPriorityMapsEveryLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
level slog.Level
want int
}{
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
{"debug", slog.LevelDebug, androidlog.PrioDebug},
{"info", slog.LevelInfo, androidlog.PrioInfo},
{"warn", slog.LevelWarn, androidlog.PrioWarn},
{"error", slog.LevelError, androidlog.PrioError},
// slog's levels are open, so a caller may sit between two of
// the named ones. Each lands in the band beneath it, which is
// what slog's own level naming does ("INFO+2").
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
{"above error", slog.LevelError + 4, androidlog.PrioError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := androidlog.Priority(tt.level); got != tt.want {
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
}
})
}
}
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
//
// android.go asserts these against android/log.h at compile time, but
// only a cross-compiler ever builds that file. This is the assertion
// that runs in CI, and the numbers are written out longhand on purpose
// -- comparing a constant to itself would pass on any renumbering.
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
got int
want int
}{
{"verbose", androidlog.PrioVerbose, 2},
{"debug", androidlog.PrioDebug, 3},
{"info", androidlog.PrioInfo, 4},
{"warn", androidlog.PrioWarn, 5},
{"error", androidlog.PrioError, 6},
{"fatal", androidlog.PrioFatal, 7},
} {
if tt.got != tt.want {
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
}
}
}
// TestRecordReachesTheSink is the whole point of the package: a line
// written with slog arrives, under the app's tag, at the right
// priority.
func TestRecordReachesTheSink(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
got := rec.only(t)
if got.prio != androidlog.PrioError {
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
}
if got.tag != androidlog.Tag {
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
}
if !strings.Contains(got.msg, "application error") {
t.Errorf("message %q does not carry the message", got.msg)
}
if !strings.Contains(got.msg, `err=boom`) {
t.Errorf("message %q does not carry the attribute", got.msg)
}
}
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
// avoid.
//
// The debug build carries `applicationIdSuffix ".dev"`, so it is
// installed as app.yellowjacket.dev -- and it is the *only* build whose
// WebView can be inspected, so it is the build anyone debugging this
// app is running. A tag derived from the application id therefore
// differs between the build being looked at and the build the filter
// was written for, which is the failure this whole issue is about
// wearing a different hat.
func TestTheTagIsNotTheApplicationID(t *testing.T) {
t.Parallel()
if strings.Contains(androidlog.Tag, ".") {
t.Errorf(
"tag %q looks like an application id; it must be stable "+
"across the debug suffix",
androidlog.Tag,
)
}
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
// truncated tag matches no filter.
if len(androidlog.Tag) > 23 {
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
}
}
// TestTimeAndLevelAreDropped checks the formatting decision.
//
// logcat stamps every entry with a timestamp and a priority letter, so
// carrying slog's own is the same information twice on a 424px screen.
func TestTimeAndLevelAreDropped(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
got := rec.only(t).msg
if strings.Contains(got, "time=") {
t.Errorf("message %q still carries a timestamp", got)
}
if strings.Contains(got, "level=") {
t.Errorf("message %q still carries a level", got)
}
if !strings.Contains(got, "files=1577") {
t.Errorf("message %q lost its attributes with them", got)
}
}
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
// the phone rather than here.
//
// Dropping slog's built-in time and level by key alone also drops a
// caller's attribute of the same name, because ReplaceAttr sees an
// empty group path for both. The probe that verified this package on
// the device wrote slog.Info("...", "level", "info") and logcat showed
// the message with no attributes at all.
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
got := rec.only(t).msg
for _, want := range []string{"level=info", "time=soon"} {
if !strings.Contains(got, want) {
t.Errorf("message %q lost the caller's %q", got, want)
}
}
// And slog's own are still gone: the built-in level renders as a
// bare word like INFO, never as the caller's value.
if strings.Contains(got, "level=INFO") {
t.Errorf("message %q carries slog's own level", got)
}
}
// TestLevelIsHonoured checks that Enabled reaches the delegate.
func TestLevelIsHonoured(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelWarn)
log.Info("not this one")
log.Warn("this one")
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
t.Errorf("wrong record survived: %q", got)
}
}
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
// delegates rather than implements -- the reason it delegates at all.
func TestGroupsAndAttrsSurvive(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelInfo).
With("component", "player").
WithGroup("track")
log.Info("loaded", "path", "/sdcard/Music/a.flac")
got := rec.only(t).msg
for _, want := range []string{
"component=player",
"track.path=/sdcard/Music/a.flac",
} {
if !strings.Contains(got, want) {
t.Errorf("message %q is missing %q", got, want)
}
}
}
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
// mutex rather than taking a new one.
//
// Two loggers derived from one write into the same buffer, so a second
// mutex would guard nothing and a concurrent pair would splice each
// other's bytes into a single line -- which reads as corrupted logs
// under load and as nothing at all in a test that logs once.
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
t.Parallel()
rec := &recorder{}
base := newLogger(rec, slog.LevelInfo)
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
log := base.With("worker", i).WithGroup("g")
for range 50 {
log.Info("tick", "n", i)
}
}()
}
wg.Wait()
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.entries) != 8*50 {
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
}
for _, e := range rec.entries {
if strings.Count(e.msg, "msg=tick") != 1 {
t.Fatalf("interleaved line: %q", e.msg)
}
}
}
// TestChunkLeavesShortLinesAlone is the common case: no numbering
// appears on a record that was never going to be truncated.
func TestChunkLeavesShortLinesAlone(t *testing.T) {
t.Parallel()
got := androidlog.Chunk("msg=short")
if len(got) != 1 || got[0] != "msg=short" {
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
}
}
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
// silently.
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
t.Parallel()
const n = 9000
long := strings.Repeat("x", n)
parts := androidlog.Chunk(long)
if len(parts) < 2 {
t.Fatalf("a %d-byte line was not split", n)
}
var payload strings.Builder
for i, p := range parts {
if len(p) > 4000 {
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
}
_, rest, found := strings.Cut(p, ") ")
if !found {
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
}
payload.WriteString(rest)
}
if payload.String() != long {
t.Errorf("the parts do not reassemble into the input")
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ WHERE id = ? AND (mbid IS NULL OR mbid = '');
-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '');
AND (mbid IS NULL OR mbid = '')
LIMIT ?;
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
+3 -2
View File
@@ -331,6 +331,7 @@ const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBI
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?
`
type GetAlbumsWithPendingReleaseMBIDRow struct {
@@ -338,8 +339,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
if err != nil {
return nil, err
}
+35 -31
View File
@@ -2,6 +2,7 @@ package explore
import (
"context"
"database/sql"
"log/slog"
"math"
"sort"
@@ -12,6 +13,7 @@ import (
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -279,37 +281,32 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
// pendingReleaseMBIDs is the albums this pass has work to do on.
//
// It is separate from the pass, and returns its error rather than
// logging it, so that a test can assert the statement runs against the
// real schema. That is not a general preference -- it is this
// statement's history: it named `release_groups`, a table plan 013
// renamed to `albums`, so it failed on every launch since e7748f1 and
// the pass returned quietly having done nothing. A test of the pass
// as a whole cannot see that, because a query error and an empty
// library are the same early return.
func (e *Service) pendingReleaseMBIDs(
ctx context.Context,
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
ctx, releaseGroupMBIDBackfillMaxPerRun,
)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
pending, err := e.pendingReleaseMBIDs(ctx)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
if len(pending) == 0 {
return
}
@@ -335,7 +332,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
job.progress(i, len(pending))
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
@@ -344,12 +341,19 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
continue
}
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
// The writer, not ReadQueries: an UPDATE issued on the
// query-only pool fails at runtime with "attempt to write a
// readonly database".
if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{
String: release.ReleaseGroupMBID,
Valid: true,
},
ID: p.ID,
},
); err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
+250
View File
@@ -0,0 +1,250 @@
package explore
import (
"database/sql"
"log/slog"
"strconv"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The release-group MBID backfill queried `release_groups`, a table
// plan 013 renamed to `albums`, so it failed on its first statement on
// every launch from e7748f1 until #189 -- and the pass swallowed that,
// because a query error and an empty library are the same early
// return. Nothing noticed for two reasons worth keeping in mind:
//
// - the statement was **raw SQL**, so sqlc never read it. Every other
// statement in the repo was renamed by the same change because sqlc
// reads sql/schemas/ and cannot generate against a table that is not
// declared. The two sqlc queries this now calls were written by 013
// and left uncalled.
// - it needs no network and no fixture library to reproduce. The
// failure is at prepare time.
// seedPendingAlbum inserts an album whose files carried a release MBID
// but no release-group MBID, which is what `library.updateMBIDs`
// leaves behind for this pass to resolve.
func seedPendingAlbum(
t *testing.T,
db *database.DB,
name, pendingMBID string,
) int64 {
t.Helper()
res, err := db.ExecContext(
"INSERT INTO albums (name, artist_credit, pending_release_mbid) "+
"VALUES (?, ?, ?)",
name, "Test Artist", pendingMBID,
)
if err != nil {
t.Fatalf("insert albums row: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
func newPendingTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
// TestPendingReleaseMBIDsRunsAgainstTheRealSchema is the regression.
//
// It asserts the statement *runs*, which is the whole of what was
// broken: against the old raw SQL this returns
// "no such table: release_groups" rather than a row.
func TestPendingReleaseMBIDsRunsAgainstTheRealSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
want := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 {
t.Fatalf("got %d pending albums, want 1", len(pending))
}
if pending[0].ID != want {
t.Errorf("got album id %d, want %d", pending[0].ID, want)
}
if got := pending[0].PendingReleaseMbid.String; got != "release-mbid-1" {
t.Errorf("got pending mbid %q, want %q", got, "release-mbid-1")
}
}
// TestOnlyUnresolvedAlbumsAreReturned pins the two conditions that make
// the pass idempotent, since between them they are what stops it doing
// the same MusicBrainz lookups on every launch forever.
func TestOnlyUnresolvedAlbumsAreReturned(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
pendingID := seedPendingAlbum(t, db, "Still Pending", "release-mbid-1")
// Already resolved: it has a real MBID, so there is nothing to
// look up even though a marker is still sitting on it.
resolved := seedPendingAlbum(t, db, "Already Resolved", "release-mbid-2")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "rg-mbid", Valid: true},
ID: resolved,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
// Never had a release MBID to resolve in the first place, which is
// most of a library.
seedPendingAlbum(t, db, "Nothing Pending", "")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 || pending[0].ID != pendingID {
t.Fatalf(
"got %d albums %v, want only the unresolved one (%d)",
len(pending), pending, pendingID,
)
}
}
// TestResolvingClearsTheMarker is the other half: once the lookup has
// answered, the album must stop being a candidate, or the pass repeats
// the same live MusicBrainz call on every launch.
func TestResolvingClearsTheMarker(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
// The writer, deliberately: this is an UPDATE, and the read pool
// would refuse it at runtime.
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "resolved-rg-mbid", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 0 {
t.Fatalf("a resolved album is still a candidate: %v", pending)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "resolved-rg-mbid" {
t.Errorf("album mbid = %q, want the resolved one", album.Mbid.String)
}
if album.PendingReleaseMbid.Valid &&
album.PendingReleaseMbid.String != "" {
t.Errorf(
"the pending marker survived as %q",
album.PendingReleaseMbid.String,
)
}
}
// TestAResolvedMBIDIsNeverOverwritten covers the guard in the UPDATE.
//
// The pass runs against rows it read earlier, and a rescan can resolve
// an album from its tags in between -- a real MBID from the file must
// win over one this pass inferred from a release.
func TestAResolvedMBIDIsNeverOverwritten(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "from-the-tags", Valid: true},
ID: id,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "from-the-backfill", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "from-the-tags" {
t.Errorf(
"album mbid = %q, want the tagged one to have won",
album.Mbid.String,
)
}
}
// TestThePassIsBounded checks the LIMIT.
//
// Each row costs a live MusicBrainz lookup on a 1 req/s limiter shared
// with every page the user can open, so an unbounded read is a run that
// lasts as long as the library is untagged. The sqlc query 013 wrote
// had no LIMIT; the raw statement it was replacing did.
func TestThePassIsBounded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
for i := range releaseGroupMBIDBackfillMaxPerRun + 10 {
seedPendingAlbum(
t, db,
"Album "+string(rune('A'+i%26))+strconv.Itoa(i),
"release-mbid-"+strconv.Itoa(i),
)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != releaseGroupMBIDBackfillMaxPerRun {
t.Errorf(
"got %d albums, want the run bounded at %d",
len(pending), releaseGroupMBIDBackfillMaxPerRun,
)
}
}
+8
View File
@@ -74,6 +74,14 @@ func ExtractTags(path string) (*TrackMetadata, error) {
// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
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)
if err != nil {
// No tags found is not necessarily an error - return empty metadata
+68
View File
@@ -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
}
+58
View File
@@ -47,6 +47,39 @@ type BufferedStreamer struct {
// seek bar and suppresses its interpolation.
starved int
starvedSince time.Time
// underruns accumulates for the life of this streamer, where
// starved is reset by every arriving sample.
//
// The two answer different questions and only the first was being
// asked. starved is a *stall* detector: it exists to end a track
// whose source has died, so it forgets a run the moment audio
// resumes -- which is exactly the case this counts. A hundred 20ms
// underruns a minute never approach the give-up threshold and were
// invisible to the log, the UI and every test tier, while being
// audible as static: an underrun is served as a run of zeros
// spliced into the waveform, and a step discontinuity at each edge
// is what a click is.
underruns UnderrunStats
}
// UnderrunStats is what the ring buffer missed, cumulatively.
//
// Samples rather than milliseconds because this type does not know the
// sample rate -- the player does, and converts at the point of
// reporting.
type UnderrunStats struct {
// Runs is the number of *episodes*: transitions from healthy into
// starved. Calls is how many Stream calls were served with
// silence, and Samples is how much silence that was.
//
// Runs is the count that means something audible. One episode is
// one pop however many calls it spans, and the ratio of the two is
// how long the average episode was -- which is what separates
// "clicking" from "dropping out".
Runs int64
Calls int64
Samples int64
}
// The silence fill is bounded by both a duration and a run of calls,
@@ -226,6 +259,13 @@ func (bs *BufferedStreamer) Stream(
// but only for a bounded stretch, because "forever" is
// reported upward as healthy playback and there is no watchdog
// above this to notice otherwise.
if bs.starved == 0 {
bs.underruns.Runs++
}
bs.underruns.Calls++
bs.underruns.Samples += int64(len(samples))
bs.starved++
if bs.starvedSince.IsZero() {
@@ -269,6 +309,20 @@ func (bs *BufferedStreamer) Stream(
return n, true
}
// Underruns returns the cumulative underrun count.
//
// It is a snapshot rather than a live view, and it is read from
// outside the audio callback: counting happens in Stream, under the
// lock it already takes, because that path has a real-time deadline
// and anything that allocates or formats on it is a cause of the
// defect it is measuring rather than a measurement of it.
func (bs *BufferedStreamer) Underruns() UnderrunStats {
bs.mu.Lock()
defer bs.mu.Unlock()
return bs.underruns
}
// Err returns any error encountered by the source streamer.
func (bs *BufferedStreamer) Err() error {
bs.mu.Lock()
@@ -298,6 +352,10 @@ func (bs *BufferedStreamer) Flush() {
// resetStarvationLocked forgets an underrun run. Must be called with
// bs.mu held.
//
// Deliberately does not touch bs.underruns: forgetting the run is what
// makes starved a stall detector, and remembering it is the whole
// point of the counter beside it.
func (bs *BufferedStreamer) resetStarvationLocked() {
bs.starved = 0
bs.starvedSince = time.Time{}
+279
View File
@@ -0,0 +1,279 @@
package player
import (
"sync"
"testing"
"time"
)
// An underrun is audible and nothing counted it (#135).
//
// The distinction these tests exist for is that `starved` and
// `underruns` disagree on purpose. `starved` is a stall detector: it is
// reset by every arriving sample, because its job is to end a track
// whose source has died and a source that is merely slow must not be
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
// door). That reset is exactly what made the audible case invisible --
// a hundred short underruns a minute never approach the give-up
// threshold, and each one is a run of zeros spliced into the waveform
// with a step discontinuity at both edges.
// TestAnEmptyRingIsCounted is the measurement itself: silence served
// for a missing sample is recorded rather than merely tolerated.
func TestAnEmptyRingIsCounted(t *testing.T) {
t.Parallel()
// A source that never produces is the cleanest way to make the
// ring empty on demand; the stall budget is far longer than the
// handful of calls below.
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
if got := bs.Underruns(); got != (UnderrunStats{}) {
t.Fatalf("a fresh streamer already reports %+v", got)
}
buf := make([][2]float64, 256)
for range 3 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
got := bs.Underruns()
if got.Calls != 3 {
t.Errorf("Calls = %d, want 3", got.Calls)
}
if got.Samples != int64(3*len(buf)) {
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
}
// Three consecutive silent calls are one episode, not three. That
// is the number that means something audible: one interruption is
// one pop however many callbacks it spans.
if got.Runs != 1 {
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
}
}
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
// waveform, which is the reason to count it at all.
func TestSilenceIsWhatIsCounted(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 64)
for i := range buf {
buf[i] = [2]float64{0.5, 0.5}
}
n, ok := bs.Stream(buf)
if !ok || n != len(buf) {
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
}
for i := range buf {
if buf[i] != ([2]float64{}) {
t.Fatalf("sample %d is %v, want silence", i, buf[i])
}
}
if got := bs.Underruns().Samples; got != int64(len(buf)) {
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
}
}
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
// audio arriving between two underruns makes them two, because that is
// two interruptions and two clicks.
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
t.Parallel()
// A source that yields nothing until it is fed, so the ring can be
// emptied, filled and emptied again on demand.
src := &gatedStreamer{}
bs := NewBufferedStreamer(src, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
starve := func() {
t.Helper()
for range 2 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
}
// feed lets exactly one bufferful through and drains it, so the
// ring is empty again on return. Allowing more would mean the
// starve() after it drained real audio instead of underrunning,
// which is what the first version of this test did -- it reported
// one episode and looked like the counter was wrong.
feed := func() {
t.Helper()
src.allow(len(buf))
// The read-ahead is a goroutine, so wait for real samples
// rather than assuming they have landed.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, ok := bs.Stream(buf)
if ok && n > 0 && buf[0] != ([2]float64{}) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the source never delivered a sample")
}
starve()
feed()
starve()
if got := bs.Underruns().Runs; got < 2 {
t.Errorf(
"Runs = %d, want at least 2 -- audio in between makes two "+
"episodes, not one",
got,
)
}
}
// TestTheStallResetDoesNotClearTheCounter is the regression this file
// is really about.
//
// resetStarvationLocked runs on every arriving sample and on every
// Flush. If it cleared the cumulative count too, the counter would
// report zero on exactly the workload it exists to measure -- a stream
// that underruns repeatedly but always recovers -- which is
// indistinguishable from healthy playback and is what the code did
// before #135.
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
before := bs.Underruns()
if before.Runs == 0 {
t.Fatal("nothing was counted, so the reset cannot be tested")
}
// Both of the ways a run is forgotten.
bs.mu.Lock()
bs.resetStarvationLocked()
bs.mu.Unlock()
bs.Flush()
if got := bs.Underruns(); got != before {
t.Errorf(
"forgetting the stall run also discarded the count: %+v, "+
"want %+v",
got, before,
)
}
}
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
// the reporting side.
//
// The counter belongs to the streamer and the streamer is replaced on
// every track, so a baseline carried across a track change is the
// previous track's total subtracted from a fresh zero. The load path
// resets the baseline, and this clamps as well -- a negative count in a
// log line reads as a broken instrument, which would discredit the
// measurement rather than merely mis-state it.
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
t.Parallel()
tests := []struct {
name string
now UnderrunStats
last UnderrunStats
want UnderrunStats
}{
{
name: "ordinary progress",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
},
{
name: "nothing happened",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
want: UnderrunStats{},
},
{
name: "a new streamer, with a stale baseline",
now: UnderrunStats{},
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
want: UnderrunStats{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := underrunDelta(tt.now, tt.last); got != tt.want {
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
}
})
}
}
// gatedStreamer produces only what it has been allowed to, and
// otherwise stalls without ending -- so a test can decide exactly when
// the ring runs dry.
type gatedStreamer struct {
mu sync.Mutex
remaining int
}
func (g *gatedStreamer) allow(n int) {
g.mu.Lock()
defer g.mu.Unlock()
g.remaining += n
}
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.remaining <= 0 {
return 0, true
}
n := min(len(samples), g.remaining)
for i := range n {
samples[i] = [2]float64{0.25, 0.25}
}
g.remaining -= n
return n, true
}
func (g *gatedStreamer) Err() error { return nil }
+90 -10
View File
@@ -39,16 +39,21 @@ type Player struct {
// via the queue).
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
// lastUnderruns is the previous report, so the 1 Hz log can say
// what happened in the last second and stay quiet when nothing did.
// It is reset with the streamer, in loadFileLocked.
lastUnderruns UnderrunStats
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
@@ -292,9 +297,78 @@ func (p *Player) emitPositionIfPlaying() {
return
}
p.reportUnderrunsLocked()
p.emitPositionLocked()
}
// underrunDelta is what happened since the last report.
//
// It clamps at zero rather than subtracting blind, because the counter
// belongs to the *streamer* and the streamer is replaced on every
// track: a baseline carried across that boundary is the previous
// track's total subtracted from a fresh zero, which is negative. That
// is repaired at the load (lastUnderruns is reset with the streamer)
// and clamped here as well, because a negative count in a log line
// reads as a broken instrument and would discredit the measurement
// this exists to make.
func underrunDelta(now, last UnderrunStats) UnderrunStats {
return UnderrunStats{
Runs: max(0, now.Runs-last.Runs),
Calls: max(0, now.Calls-last.Calls),
Samples: max(0, now.Samples-last.Samples),
}
}
// reportUnderrunsLocked logs what the ring buffer missed, at most once
// a second and only when the number moved. Must be called with p.mu
// held.
//
// **An underrun is audible and nothing counted it** (#135). The ring
// serves silence when it is empty, so a run of zeros is spliced into
// the waveform and the step discontinuity at each edge is a click; a
// series of short ones is static. Everything that makes one likelier
// is worse on a phone than on a desktop -- slower storage, a governor
// that parks cores, background work, GC -- and no tier here can see it,
// since CI's audio device is a null sink chosen because it keeps time.
//
// Three things about the reporting are deliberate.
//
// **It is on the 1 Hz position ticker rather than in Stream.** Stream
// runs on the speaker callback's real-time deadline, and a log line
// there would allocate, format and write on the exact path whose
// missed deadline is the defect -- measuring by making it worse.
//
// **An unchanged count is not logged.** That is emitStatus' rule one
// package over: a healthy player is silent, so anything in the log is
// news, and the line appears exactly while it is popping. Reading it
// off a device means `make android-logs` with the audio audible.
//
// **It is Info rather than Debug**, because the default level is Info
// and a phone has no convenient way to set YJ_LOG_LEVEL -- a debug
// line here would be a counter nobody on the affected platform can
// read, which is the shape of the bug that made #160 necessary.
func (p *Player) reportUnderrunsLocked() {
if p.buffered == nil {
return
}
stats := p.buffered.Underruns()
if stats == p.lastUnderruns {
return
}
since := underrunDelta(stats, p.lastUnderruns)
p.lastUnderruns = stats
slog.Info("audio underrun",
"runs", since.Runs,
"calls", since.Calls,
"silenceMs", speakerSampleRate.D(int(since.Samples)).Milliseconds(),
"trackRuns", stats.Runs,
"trackSilenceMs", speakerSampleRate.D(int(stats.Samples)).Milliseconds(),
)
}
// emitPositionLocked pushes the current position to the frontend.
// Must be called with p.mu held.
func (p *Player) emitPositionLocked() {
@@ -484,6 +558,12 @@ func (p *Player) updateStreamers(
p.resampled, int(speakerSampleRate)*2,
)
// The counter belongs to the streamer, so the baseline it is
// reported against has to go with it -- otherwise the first report
// of a new track is the previous track's total subtracted from
// zero, which is negative and looks like the instrument is broken.
p.lastUnderruns = UnderrunStats{}
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.buffered}
+183
View File
@@ -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
}
+211
View File
@@ -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")
}
}
+69
View File
@@ -52,6 +52,75 @@ func UseHomeOverride(base string) {
_ = os.Setenv(envHomeOverride, base)
}
// envTempDir is the variable Go's os.TempDir() reads, and through it
// every library in the process that asks for a temporary file.
const envTempDir = "TMPDIR"
// tempDirName is the subdirectory of the app's own storage that
// becomes that answer.
const tempDirName = "tmp"
// UseTempDir gives the process a temporary directory that exists.
//
// **Android has no /tmp and hands an app no TMPDIR**, and Go's
// os.TempDir() falls back to "/tmp" when the variable is unset -- so
// every library in the process that wants scratch space is handed a
// path that has never existed. SQLite is the one that noticed: an
// INSERT ... SELECT large enough to spill returned
// SQLITE_IOERR_GETTEMPPATH (disk I/O error 6410), which is how the
// champion search index came to fail its rebuild on every launch while
// the app otherwise looked healthy (#190).
//
// It is the *class* that is fixed here rather than that statement.
// Anything that spills fails the same way on that platform -- large
// sorts, large joins, VACUUM -- so the repair belongs at the process's
// one answer to the question rather than at each caller. The
// alternative considered was PRAGMA temp_store = MEMORY, which is
// cheaper and more local and is a promise that every future spill fits
// in RAM on a phone; the catalog is the largest thing in this app and
// that is not a promise worth making silently.
//
// The rules are UseHomeOverride's, for the same reasons. **An empty
// base is a no-op**, because that is what
// application.Mobile.StoragePath() returns on desktop -- so this needs
// no build tag and changes nothing off mobile, where /tmp is real. And
// **an explicit TMPDIR wins**, so anyone who set one deliberately gets
// what they asked for; nothing sets it on the platform this exists for.
//
// It returns its error rather than swallowing it because a temp
// directory that could not be created is the same silent failure one
// step earlier, and since #160 a log line on that platform is
// something a person can actually read.
func UseTempDir(base string) error {
if base == "" || os.Getenv(envTempDir) != "" {
return nil
}
dir := filepath.Join(base, tempDirName)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("could not make the temp directory %s: %w", dir, err)
}
// Writability is checked rather than assumed: the whole failure
// this repairs is a directory that is named and cannot be used, and
// MkdirAll on an existing unwritable directory succeeds.
probe, err := os.CreateTemp(dir, "probe")
if err != nil {
return fmt.Errorf("temp directory %s is not writable: %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
if err := os.Setenv(envTempDir, dir); err != nil {
return fmt.Errorf("could not set %s: %w", envTempDir, err)
}
return nil
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
+113
View File
@@ -87,3 +87,116 @@ func TestUseHomeOverride(t *testing.T) {
})
}
}
// UseTempDir carries UseHomeOverride's two rules for the same reasons,
// plus one of its own: the directory it names has to be usable.
//
// **The only tier that can compile the platform this exists for is a
// phone**, so everything decidable off one is decided here -- which is
// androidpayload.go's discipline, and is why the platform call is a
// parameter rather than something this package reaches for. The
// device's half is a single measurement: no /tmp, no TMPDIR (#190).
func TestUseTempDir(t *testing.T) {
t.Run("an empty base is a no-op", func(t *testing.T) {
// This is the desktop case in full: StoragePath() answers ""
// off mobile, where /tmp is real and must be left alone.
t.Setenv(envTempDir, "")
if err := UseTempDir(""); err != nil {
t.Fatalf("UseTempDir(\"\") = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s = %q, want it untouched", envTempDir, got)
}
})
t.Run("an explicit TMPDIR wins", func(t *testing.T) {
const chosen = "/somewhere/deliberate"
// The base is taken before TMPDIR moves, because t.TempDir()
// reads TMPDIR too -- which is the same fact this function is
// about, met from the other side.
base := t.TempDir()
t.Setenv(envTempDir, chosen)
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != chosen {
t.Errorf("%s = %q, want the explicit %q", envTempDir, got, chosen)
}
})
t.Run("points at a real directory under the base", func(t *testing.T) {
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
got := os.Getenv(envTempDir)
want := filepath.Join(base, tempDirName)
if got != want {
t.Fatalf("%s = %q, want %q", envTempDir, got, want)
}
// The whole failure being repaired is a temp directory that is
// named and does not exist, so naming one is not enough.
info, err := os.Stat(got)
if err != nil {
t.Fatalf("the temp directory was named but not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("%s is not a directory", got)
}
})
t.Run("os.TempDir then answers with it", func(t *testing.T) {
// The point of setting the variable at all: this is what every
// library in the process reads, SQLite's driver included.
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.TempDir(); got != filepath.Join(base, tempDirName) {
t.Errorf("os.TempDir() = %q, want the directory we made", got)
}
})
t.Run("an unwritable directory is an error, not a silent success", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write anywhere, so there is nothing to refuse")
}
base := t.TempDir()
// MkdirAll on an existing directory succeeds whatever its
// mode, so without the write probe this case would set TMPDIR
// to a directory nothing can use -- which is the bug again,
// one directory over.
if err := os.Mkdir(filepath.Join(base, tempDirName), 0o500); err != nil {
t.Fatalf("prepare the unwritable directory: %v", err)
}
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err == nil {
t.Fatal("UseTempDir accepted a directory it cannot write to")
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s was set to %q despite the failure", envTempDir, got)
}
})
}
+5 -4
View File
@@ -13,9 +13,10 @@ import (
// indistinguishable from never having written one. So these assert the
// 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
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
// chunk -- which is why every other test here reads that chunk itself.
// WAV was the exception until #104 -- dhowden/tag has no RIFF reader,
// so metadata.ExtractTags could not see a WAV's ID3 chunk and this
// 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) {
t.Parallel()
@@ -91,7 +92,7 @@ func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
},
{
name: "wav",
read: readWavID3Tags,
read: viaScanner,
write: func(t *testing.T, dir string) string {
t.Helper()
+11 -105
View File
@@ -8,116 +8,22 @@ import (
"io"
"log/slog"
"os"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil"
"yellowjacket/backend/riff"
)
// Sentinel errors for WAV RIFF operations.
var (
errRF64NotSupported = errors.New("RF64 files are not yet supported")
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"
}
// errFileTooLargeForWAV is the one RIFF error that belongs to the
// writer; reading rejects a container in backend/riff.
var errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
// the given chunks in order and appending the id3Data as the final
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
// 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:
// 4 bytes (WAVE form type)
// + 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)
for _, c := range chunks {
sz := uint64(len(c.data))
sz := uint64(len(c.Data))
riffPayload += 8 + sz
if sz%2 != 0 {
@@ -162,7 +68,7 @@ func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
// Write each preserved chunk.
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
}
}
@@ -225,7 +131,7 @@ func writeWavTags(
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
// AtomicWrite creates the replacement file.
@@ -237,13 +143,13 @@ func writeWavTags(
// Separate preserved chunks from existing ID3 data.
var (
preserved []riffChunk
preserved []riff.Chunk
existingID3 []byte
)
for _, c := range allChunks {
if isID3ChunkID(c.id) {
existingID3 = c.data
if riff.IsID3(c.ID) {
existingID3 = c.Data
} else {
preserved = append(preserved, c)
}
+103 -17
View File
@@ -12,6 +12,7 @@ import (
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/metadata"
"yellowjacket/backend/riff"
)
// 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")
}
// 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) {
t.Parallel()
@@ -282,7 +365,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open original: %v", err)
}
origChunks, err := parseRIFF(origFile)
origChunks, err := riff.Parse(origFile)
_ = origFile.Close()
if err != nil {
@@ -292,7 +375,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// Record original chunk data by ID string.
origData := map[string][]byte{}
for _, c := range origChunks {
origData[string(c.id[:])] = c.data
origData[string(c.ID[:])] = c.Data
}
// Write a tag to trigger RIFF rewrite.
@@ -309,7 +392,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open after write: %v", err)
}
newChunks, err := parseRIFF(newFile)
newChunks, err := riff.Parse(newFile)
_ = newFile.Close()
if err != nil {
@@ -320,7 +403,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
origNonID3 := 0
for _, c := range origChunks {
if !isID3ChunkID(c.id) {
if !riff.IsID3(c.ID) {
origNonID3++
}
}
@@ -328,7 +411,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
newNonID3 := 0
for _, c := range newChunks {
if !isID3ChunkID(c.id) {
if !riff.IsID3(c.ID) {
newNonID3++
}
}
@@ -359,17 +442,17 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// in chunks and its data matches want byte-for-byte.
func checkChunkPreserved(
t *testing.T,
chunks []riffChunk,
chunks []riff.Chunk,
idStr string,
want []byte,
) {
t.Helper()
for _, c := range chunks {
if string(c.id[:]) == idStr {
if !bytes.Equal(c.data, want) {
if string(c.ID[:]) == idStr {
if !bytes.Equal(c.Data, want) {
t.Errorf("chunk %q data changed: got %d bytes, want %d",
idStr, len(c.data), len(want))
idStr, len(c.Data), len(want))
}
return
@@ -430,7 +513,7 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
buf.WriteString("WAVE")
// 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")
_ = binary.Write(&buf, binary.LittleEndian, uint32(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
// 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).
// Using bogem/id3v2.ParseReader handles all cases correctly.
//
// metadata.ExtractTags reads a WAV since #104 and is what the round
// 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(
t *testing.T,
path string,
@@ -470,17 +556,17 @@ func readWavID3Tags(
defer func() { _ = f.Close() }()
chunks, err := parseRIFF(f)
chunks, err := riff.Parse(f)
if err != nil {
t.Fatalf("parseRIFF: %v", err)
t.Fatalf("riff.Parse: %v", err)
}
// Find the id3 chunk.
var id3Data []byte
for _, c := range chunks {
if isID3ChunkID(c.id) {
id3Data = c.data
if riff.IsID3(c.ID) {
id3Data = c.Data
break
}
-127
View File
@@ -1,127 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks the app's own listeners, on the app's own DOM, from the
* events a touch would produce and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
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 }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { test, expect } from '../support/fixtures.js';
/**
* The web view's own tap highlight, and what replaced it (#54).
*
* Two halves, and each is here because no other tier can see it.
*
* **The highlight is killed by one declaration on `html`**, which
* reaches the app's shadow roots because `-webkit-tap-highlight-color`
* is inherited and inheritance crosses a shadow boundary. That is a
* property of `index.css`, and `index.css` is loaded by the real app
* and by nothing else the component tier mounts a component with no
* page stylesheet at all, which is the same reason the theme's ramps
* are invisible to it.
*
* **The press state is measured rather than read.** The component tier
* asserts the shape of the stylesheet (which rule is inside which
* query, and that the press selector carries a state class), because
* `:active` cannot be forced there. Here there is a real pointer: hold
* the button down on a real row of the real list and read what the row
* became. That is the assertion that would fail if the rule were
* hoisted, renamed, or lost to `.selected`.
*
* What neither half is, is the device. Chrome 113's WebView is where
* the grey box was reported and where a finger is; the numbers from it
* are on the PR.
*/
type Page = import('@playwright/test').Page;
/** The phone this work was measured against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** The computed tap-highlight colour of a node inside a shadow root. */
const tapHighlight = (page: Page, host: string, inner: string) =>
page.evaluate(
([hostSel, innerSel]) => {
const el = document
.querySelector(hostSel!)
?.shadowRoot?.querySelector(innerSel!);
if (!el) return null;
return getComputedStyle(el).getPropertyValue(
'-webkit-tap-highlight-color',
);
},
[host, inner],
);
test.describe('the tap highlight', () => {
test('is transparent inside a shadow root, from one rule on html', async ({
app,
browserName,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
const row = await tapHighlight(app, 'track-list', '.track-row');
expect(row).not.toBeNull();
// The property is a WebKit extension that only iOS honours, so an
// engine is free not to report one at all. Chromium always does —
// measured at rgba(0, 0, 0, 0.18) with the rule removed, which is
// the grey box the report describes — so the assertion is not
// skippable there, and nothing this app can do makes the property
// disappear on an engine that has it.
test.skip(
row === '',
`${browserName} reports no -webkit-tap-highlight-color to read`,
);
expect(row).toBe('rgba(0, 0, 0, 0)');
});
});
test.describe('the press state that replaced it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(app.locator('track-list').first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.mouse.up();
await app.setViewportSize({ width: 1440, height: 900 });
});
test('shows on the row being pressed, and on that row only', async ({
app,
}) => {
const rows = await app.evaluate(() => {
const found = document
.querySelector('track-list')
?.shadowRoot?.querySelectorAll('.track-row');
if (!found || found.length < 2) return null;
const rect = found[1]!.getBoundingClientRect();
return {
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
};
});
expect(rows).not.toBeNull();
const backgrounds = () =>
app.evaluate(() => {
const found = document
.querySelector('track-list')!
.shadowRoot!.querySelectorAll('.track-row');
return {
pressed: getComputedStyle(found[1]!).backgroundColor,
neighbour: getComputedStyle(found[2]!).backgroundColor,
};
});
await app.mouse.move(rows!.x, rows!.y);
await app.mouse.down();
const held = await backgrounds();
// The press overlay, from the theme rather than from a literal in
// a component: rgba(255, 255, 255, 0.12) on both dark ramps.
expect(held.pressed).toBe('rgba(255, 255, 255, 0.12)');
expect(held.neighbour).not.toBe(held.pressed);
await app.mouse.up();
});
});
+135
View File
@@ -0,0 +1,135 @@
import {
test,
expect,
callBinding,
openTheQueue,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* #67 a name is not a link on a phone, and the menu is where it went.
*
* The queue panel is the surface this is visible on: its rows draw a
* track title and an artist credit as `explore-link`s at every width,
* unlike `track-list`, whose phone column set stacks title over artist
* as plain text already.
*
* **The pair is what makes either assertion mean anything.** A link
* that is gone and a menu item that never arrived is not a smaller
* affordance it is a destination the phone cannot reach, which is
* what plan 018's "no action is unreachable at any supported size"
* refuses. So each test asserts the phone and the desktop in the same
* breath: text *and* an item here, a link *and* no item there.
*
* The desktop half is also the regression guard for the change: menus
* above the breakpoint must be exactly what they were, because the name
* beside them is still a link and a menu that repeats the row is
* furniture.
*/
/** The reference device's real viewport, not a resized desktop. */
const DEVICE = { width: 424, height: 439 };
/** Wide enough that the queue is a column beside the content. */
const DESKTOP = { width: 1280, height: 800 };
const row = (app: Page, index: number) =>
app.locator(`queue-panel .track-item[data-index="${index}"]`);
/** The queue panel's own context menu, as a list of item labels. */
async function menuLabels(app: Page): Promise<string[]> {
return app.evaluate(() =>
[
...document
.querySelector('queue-panel')!
.shadowRoot!.querySelectorAll('wa-dropdown-item'),
].map((item) => item.textContent?.replace(/\s+/g, ' ').trim() ?? ''),
);
}
/**
* Queue three tracks that have an album, for the reason
* `queue-selection.spec.ts` states at length: `explore-link` routes a
* title to its *album's* page and renders plain text where it cannot
* route, so a track with no album answers this file's question with
* the wrong "no link".
*/
async function queueThree(app: Page): Promise<void> {
const paths = await app.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; Album: string; ArtistName: string }[];
return tracks
.filter((t) => t.Album !== '' && t.ArtistName !== '')
.slice(0, 3)
.map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
}
/** Open the row's context menu and read the items back. */
async function openRowMenu(app: Page, index: number): Promise<string[]> {
await row(app, index).click({ button: 'right' });
await expect
.poll(async () => (await menuLabels(app)).length)
.toBeGreaterThan(0);
return menuLabels(app);
}
test.describe('an inline name and the menu that replaces it', () => {
test.afterEach(async ({ app }) => {
await app.keyboard.press('Escape');
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* an empty queue is the state we were asking for */
});
await app.setViewportSize(DESKTOP);
});
test('a queue row is plain text on a phone and carries the destination', async ({
app,
}) => {
await app.setViewportSize(DEVICE);
await queueThree(app);
await openTheQueue(app);
await expect(row(app, 0)).toBeVisible();
// The name is text: nothing in the row is a link at all.
await expect(app.locator('queue-panel .track-item .explore-link')).toHaveCount(
0,
);
const labels = await openRowMenu(app, 0);
expect(labels).toContain('Go to Artist');
expect(labels).toContain('Go to Album');
});
test('the same row on a desktop is a link, and its menu is untouched', async ({
app,
}) => {
await app.setViewportSize(DESKTOP);
await queueThree(app);
await openTheQueue(app);
await expect(row(app, 0)).toBeVisible();
await expect(
row(app, 0).locator('.track-title .explore-link'),
).toHaveCount(1);
const labels = await openRowMenu(app, 0);
expect(labels).not.toContain('Go to Artist');
expect(labels).not.toContain('Go to Album');
});
});
+283
View File
@@ -0,0 +1,283 @@
import { test, expect } from '../support/fixtures.js';
/**
* Now Playing on a short screen (#51).
*
* #51 asks for a layout that "survives" ~424x439 with the controls
* never scrolling off. #172 measured why it did not the stacked
* layout's budget is fixed, so the art gets whatever is left, and that
* was 39px before #64 and 53px after it.
*
* **Two separate claims are asserted here, and only one of them is
* about the phone.**
*
* The first is that the art is *square*. It was not: `aspect-ratio` is
* specified not to re-derive the width when `max-height` clamps the
* height, so the art was drawn as a letterbox band and `object-fit:
* cover` cropped the cover to it — 264x53 on the reference device. The
* leftover only exceeds the width above ~843px of viewport, so this
* was every height from ~500 to ~843 as well: most phones, and any
* short window. That is ordinary CSS rather than a Chrome 113 quirk,
* so this tier can see it, and the heights below are chosen to cover
* the range rather than the one device.
*
* The second is the reflow: below 500px the art and the names sit side
* by side, which is what takes the art from 53px to 143px. That is
* asserted as a *relation between boxes* the art beside the names,
* not above them because the pixel count is a consequence of the
* arrangement and would pin this file to one device's chrome.
*
* **What this tier cannot see** is the device's engine: CI's Chromium
* and WebKit are current, and #60's clipping showed what that costs.
* Nothing here depends on Chrome 113 behaviour the sizing rules were
* checked against the device itself, at column heights of 288, 300,
* 451, 600 and 800, and the numbers are on #51.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
/**
* A tall phone, above the reflow's 500px. Roughly a Pixel 7, which is
* #51's other named device and was not attached so what is checked
* here is the layout it *should* get, not that device.
*/
const TALL_PHONE = { width: 412, height: 869 };
/** Inside the crop's old range and above the reflow: a short window. */
const SHORT_WINDOW = { width: 390, height: 700 };
/**
* The height the layout reflows at. Written down once here because the
* specs have to know which arrangement to *wait* for, not only which
* to assert.
*/
const REFLOW_AT = 500;
/** Put a track in the player, so the view has art and names to lay out. */
async function stageATrack(page: Page): Promise<void> {
await page.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
10_000,
);
});
}
/** Open the full-screen view and wait for the shell to say so. */
async function openNowPlaying(page: Page): Promise<void> {
await page.evaluate(() => {
document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
}),
);
});
await expect(page.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
// The attribute is the shell's bookkeeping and lands before the view
// has a track, so measuring on it alone races the first layout --
// which showed up as a 60x5 art on the first spec of a cold run.
//
// Waiting for a non-zero box is not enough on its own either: a
// previous test leaves the *other* arrangement on screen, and a
// stale column satisfies "has a size" perfectly. So the wait is for
// the arrangement this viewport should have, which is the thing
// every assertion below depends on. Found by this file passing one
// test at a time and failing in file order.
const wantRow = (page.viewportSize()?.height ?? 0) <= REFLOW_AT;
await page.waitForFunction(
(row: boolean) => {
const v = document.querySelector('now-playing-view');
const stack = v?.shadowRoot?.querySelector('.stack');
const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder');
const t = v?.shadowRoot?.querySelector('.transport');
if (!el || !t) return false;
// A build with no `.stack` at all is the one before this change,
// and the squareness assertions are still meaningful against it
// -- so this waits for the arrangement only where there is one to
// wait for. Otherwise reverting the component to check that these
// tests bite produces eight timeouts instead of the measurements
// that make the case.
if (stack) {
const dir = getComputedStyle(stack).flexDirection;
if (dir !== (row ? 'row' : 'column')) return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && t.getBoundingClientRect().height > 0;
},
wantRow,
);
}
/**
* The boxes this file reasons about, read in one evaluate.
*
* It reaches into the view's shadow root rather than using locators
* because the question is geometric where these boxes are *relative
* to each other* and a testid per edge would be four locators and
* four round trips to say one thing.
*/
async function boxes(page: Page) {
return page.evaluate(() => {
const v = document.querySelector('now-playing-view');
if (!v || !v.shadowRoot) return null;
const rect = (sel: string) => {
const el = v.shadowRoot!.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height,
};
};
return {
// Whichever of the two the track has; both carry the sizing.
art: rect('.art img') ?? rect('.art .placeholder'),
artBox: rect('.art'),
stack: rect('.stack'),
meta: rect('.meta'),
transport: rect('.transport'),
scrollHeight: v.scrollHeight,
clientHeight: v.clientHeight,
};
});
}
test.describe('Now Playing survives a short screen', () => {
test.beforeEach(async ({ app }) => {
await stageATrack(app);
});
/**
* The crop, at four heights spanning the range it covered. This is
* the assertion that fails on the build before this change: at
* 424x439 the art measured 264x53.
*/
for (const vp of [DEVICE, SHORT_WINDOW, TALL_PHONE, { width: 900, height: 500 }]) {
test(`draws the art square at ${vp.width}x${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await openNowPlaying(app);
const b = await boxes(app);
expect(b, 'now-playing-view did not mount').not.toBeNull();
expect(b!.art, 'neither art nor placeholder rendered').not.toBeNull();
const { width, height } = b!.art!;
expect(width, 'the art has no width').toBeGreaterThan(0);
// One pixel of slack for sub-pixel layout, and no more: the
// defect this guards was a 5:1 band.
expect(
Math.abs(width - height),
`art is ${Math.round(width)}x${Math.round(height)}, not square`,
).toBeLessThanOrEqual(1);
});
}
/**
* The promise #51 states and plan 018's matrix repeats. A floor on
* the art with the block scrolling was the other option on #172 and
* this is why it was not taken.
*/
test('never scrolls the transport off the bottom', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.transport!.bottom).toBeLessThanOrEqual(DEVICE.height);
expect(
b!.scrollHeight,
'the view scrolls, so the transport can be moved off screen',
).toBeLessThanOrEqual(b!.clientHeight + 1);
});
/**
* The reflow itself, as a relation rather than a measurement: below
* 500px the names are *beside* the art, above it they are below.
*/
test('puts the names beside the art below 500px', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.left,
'the names are not to the right of the art',
).toBeGreaterThanOrEqual(b!.artBox!.right - 1);
});
test('keeps the names below the art on a tall phone', async ({ app }) => {
await app.setViewportSize(TALL_PHONE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.top,
'the names are not below the art',
).toBeGreaterThanOrEqual(b!.artBox!.bottom - 1);
});
/**
* What the reflow actually does, stated as a mechanism rather than
* as a number: in a row the art is bounded by the row's *height*,
* so it fills it where in a column it is the leftover after the
* names, which is what made it 53px.
*
* **The pixel count is deliberately not asserted here.** Two drafts
* tried. The first compared the art against the column's leftover
* computed from the boxes on screen and passed on the broken build,
* because the subtraction goes negative when the names are taller
* than the art precisely the defect. The second put a floor of
* 100px on it, passed locally at 114 and **failed in CI at 64**: this
* app is long-lived, so a job staged by an earlier spec is still on
* screen, and the volume control renders here where it does not on
* Android. Both are chrome above and below this view, and both move
* the leftover. A test that asserts how much room CI happened to
* have is a test about the runner.
*
* The device numbers 53px to 143px are on #51, measured there,
* which is the only tier that can honestly produce them.
*/
test('fills the row with the art rather than the leftover', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.stack, 'there is no row to fill').not.toBeNull();
expect(
Math.abs(b!.artBox!.height - b!.stack!.height),
'the art does not fill the row, so it is still a leftover',
).toBeLessThanOrEqual(1);
});
});
+52
View File
@@ -98,6 +98,58 @@ test.describe('the shell on a phone', () => {
).toBeVisible();
});
test('draws "More" as a sheet on the bottom edge (#71)', async ({ app }) => {
await app.getByTestId('tab-more').click();
await expect(app.getByTestId('nav-drawer').locator('app-sidebar'))
.toBeVisible();
// What the report is about is geometry, and geometry is what no
// other assertion here can see: the side drawer was a 200px column
// opening away from the thumb that asked for it, with the rest of
// its 400px band empty. Measured rather than screenshotted, since
// the failure is a number.
//
// Polled, because a sheet *arrives*: the drawer's show animation
// translates it a full height below the fold, so a measurement
// taken the moment its content is visible reports a box hanging
// 412px off the bottom of the screen. Asking for the settled
// number is the assertion; asking once is a race.
const measure = () => app.evaluate(() => {
const nav = document.querySelector('bottom-nav');
const drawer = nav?.shadowRoot?.querySelector('wa-drawer');
const dialog = drawer?.shadowRoot?.querySelector('[part~="dialog"]');
const sidebar = nav?.shadowRoot?.querySelector('app-sidebar');
const row = sidebar?.shadowRoot?.querySelector('li button');
const box = dialog?.getBoundingClientRect();
return {
left: Math.round(box?.left ?? -1),
right: Math.round(box?.right ?? -1),
bottom: Math.round(box?.bottom ?? -1),
height: Math.round(box?.height ?? -1),
row: Math.round(row?.getBoundingClientRect().height ?? -1),
viewport: [window.innerWidth, window.innerHeight],
};
});
await expect
.poll(async () => (await measure()).bottom)
.toBe(PHONE.height);
const sheet = await measure();
expect(sheet.left).toBe(0);
expect(sheet.right).toBe(sheet.viewport[0]);
// A surface covering the whole screen is a page, not a sheet --
// which is also what leaves an outside to tap on, the only pointer
// route out of it (#171 is the same question one surface over).
expect(sheet.height).toBeLessThan(sheet.viewport[1]);
// 48px rows, from #186's touch floor and #60's context sheet.
expect(sheet.row).toBeGreaterThanOrEqual(48);
});
for (const vp of [PHONE, SMALL_PHONE]) {
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
+29 -2
View File
@@ -179,8 +179,8 @@ test.describe('the queue is a screen where it covers the content', () => {
*/
/**
* With the panel spanning the whole width the scrim has no uncovered
* pixels, so the close button is the only pointer route out of a
* With the panel spanning the whole width there is no scrim here at
* all (#171), so the close button is the only pointer route out of a
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
*/
test('offers a way out a thumb can hit', async ({ app }) => {
@@ -194,6 +194,33 @@ test.describe('the queue is a screen where it covers the content', () => {
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
/**
* #171 and it draws no scrim, because there is nowhere to tap.
*
* `.panel-content` is `width: 100%` here, so the scrim sat entirely
* underneath it: measured at 424×439, host, panel and scrim all
* 424×318. #24's tap-outside-to-close cannot exist on a surface with
* no outside, and a `cursor: pointer` layer nobody can reach is a
* claim the component cannot keep.
*
* Asserted as absence rather than by clicking, for the reason the
* issue gives: a naive phone case clicks the scrim's centre and hits
* the panel, so it passes on the build this exists to fail. The scrim
* is still real between 600 and 899px, which `queue-overlay.spec.ts`
* asserts at 900×600 by clicking it.
*/
test('draws no scrim, because a screen has no outside to tap', async ({
app,
}) => {
await openTheQueue(app);
const scrim = await queue(app).evaluate(
(el) => el.shadowRoot!.querySelector('.scrim') !== null,
);
expect(scrim).toBe(false);
});
});
/**
+56
View File
@@ -157,6 +157,62 @@ test.describe('an overlaid queue says it is over the content', () => {
});
});
/**
* #170 the other two buttons in that same row.
*
* Clear queue and Add queue to playlist predate the close button and
* were named by a `title` attribute and nothing else. Unlike the
* sliders in `control-names.spec.ts`, that is not a *missing* name:
* `title` is the last fallback in the accname order, so
* `getByRole('button', { name: 'Clear queue' })` matched them before
* this fix as well as after it measured, 1 and 1. A sweep for empty
* names cannot see a weak one, which is `a11y.26`'s complaint and the
* reason this file could have grown a green test that proved nothing.
*
* So the name is asserted twice, and the second assertion is the one
* that fails on the broken build. Taking the tooltip away and asking
* again is the property in words: **the name is not the tooltip**. It
* is what makes the button survive content being put inside it later,
* and it is the only one of the two a phone has there is no hover on
* the surface #55 turned into a full screen. Measured on `main` before
* the fix: 0 and 0.
*
* Both buttons are disabled here, because the queue starts empty and
* naming is not enablement. A disabled button is still in the
* accessibility tree, which is exactly where the complaint was.
*/
test.describe('the queue header says what its actions do', () => {
const ACTIONS = ['Clear queue', 'Add queue to playlist'];
test('names both of the older actions', async ({ app }) => {
await openQueue(app);
for (const name of ACTIONS) {
await expect(
app.getByRole('button', { name, exact: true }),
).toHaveCount(1);
}
});
test('and the names do not come from the tooltip', async ({ app }) => {
await openQueue(app);
await app.locator('#queue-panel').evaluate((el) => {
for (const button of el.shadowRoot!.querySelectorAll(
'.header-action-button',
)) {
button.removeAttribute('title');
}
});
for (const name of ACTIONS) {
await expect(
app.getByRole('button', { name, exact: true }),
).toHaveCount(1);
}
});
});
/**
* The inline panel is the mode that already worked, and the one every
* other queue spec is written against. It keeps its resize handle and
+311
View File
@@ -0,0 +1,311 @@
import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
* from plan 016 B2).
*
* The component tier proves the gestures in isolation, against markup
* it built itself. What it cannot prove is the half that made this one
* document listener instead of six: that the announced gesture reaches
* the handler a *real* component bound `track-list` delegates on the
* `lit-virtualizer` rather than binding per row and that the real
* menu opens from it, a path with its own history of opening and then
* refusing to work (see `menu-keyboard.spec.ts`).
*
* **Both halves of the reassignment are here, and the second is the
* one that matters.** #63 makes a hold on a *track row* mean selection
* mode; every other surface in the app keeps the context menu it has
* had, because an unclaimed `yj-long-press` still becomes a
* `contextmenu`. A spec that only checked the row would pass on a
* build that had silently broken the other thirteen menus.
*
* The pointer events are dispatched rather than performed: this
* project runs Desktop Chrome and Desktop Safari, neither of which has
* touch. So this is honest about what it checks the app's own
* listeners, on the app's own DOM, from the events a touch would
* produce and not about a real finger. The finger is the Android
* tier, and it found something this cannot see: Chrome 113's WebView
* fires its own `contextmenu` on a long press, which is why the module
* announces the gesture from a native event rather than standing down.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** A component's menu panel, or null while it is not rendered. */
const panel = (page: Page, host: string) =>
page.evaluate((tag) => {
const el = document
.querySelector(tag)
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
}, host);
/** How many tracks the selection bar says are selected, or null. */
const selectionCount = (page: Page) =>
page.evaluate(() => {
const bar = document
.querySelector('track-list')
?.shadowRoot?.querySelector('selection-bar');
return bar ? (bar as unknown as { count: number }).count : null;
});
/**
* Press an element, optionally dragging partway through the shape of
* a scroll that begins on a row, which must be neither gesture and
* optionally lifting, which is what makes it a tap rather than a hold.
*/
async function press(
page: Page,
selector: { host: string; inner: string },
opts: { driftY?: number; lift?: boolean } = {},
): Promise<void> {
await page.evaluate(
({ host, inner, drift, lift }) => {
const el = document
.querySelector(host)
?.shadowRoot?.querySelector(inner);
if (!el) throw new Error(`no ${inner} in ${host} to press`);
const box = el.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
if (lift) send('pointerup');
},
{
host: selector.host,
inner: selector.inner,
drift: opts.driftY ?? 0,
lift: opts.lift ?? false,
},
);
}
// `.track-row`, not `[role="row"]`: the column header is a row too, and
// it is the *first* one — a press on it is correctly ignored, which
// reads exactly like the gesture not working.
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
test.describe('a hold on a track row selects it', () => {
test.beforeEach(async ({ app }) => {
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 }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('raises the selection bar rather than the context menu', async ({
app,
}) => {
await expect.poll(() => selectionCount(app)).toBeNull();
await press(app, TRACK_ROW);
await expect
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
.toBe(1);
// The gesture is claimed, so the menu this hold used to open must
// not also be up -- on a phone that would be a sheet over the bar.
expect(await panel(app, 'track-list')).toBeNull();
});
test('is neither gesture when the press turns into a scroll', async ({
app,
}) => {
await press(app, TRACK_ROW, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await selectionCount(app)).toBeNull();
expect(await panel(app, 'track-list')).toBeNull();
});
});
test.describe('a hold anywhere else still opens the menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
// The property that let #63 reassign the hold without touching one
// of the fourteen context menus: unclaimed, it is what it was.
// Without this half, breaking all of them passes the suite.
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
await expect
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
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);
});
});
+20
View File
@@ -7,6 +7,26 @@
html {
height: 100%;
/* #54. The web view's own tap highlight -- the grey box a phone
draws over the bounding rect of whatever was tapped -- gone in
one declaration, because `-webkit-tap-highlight-color` is an
*inherited* property and an inherited property crosses a shadow
boundary. So this reaches every one of the app's shadow roots
without a rule in any of them; before it, exactly one component
(`library-status-indicator`) set it and the box appeared
everywhere else.
What it costs is the only touch feedback several surfaces had,
which is why the rows, the tab bar and the shared menu items
grew a `:active` state in the same change: removing the wrong
feedback and leaving none is not an improvement. The cards
already had one (`transform: scale(0.97)`).
`user-select` is the same argument one rule up and was already
done: the `*` rule at the top of this file is inherited into the
shadow roots too. */
-webkit-tap-highlight-color: transparent;
}
body {
+8 -5
View File
@@ -70,7 +70,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press';
import { installTouchGestures } from '@utils/touch-gestures';
import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit';
import {
@@ -87,10 +87,13 @@ setBasePath('/dist/webawesome');
// the session.
registerBundledIcons();
// The touch equivalent of a right-click, installed once for every menu
// in the app rather than per component. Harmless on a desktop: it acts
// on `pointerType === 'touch'` only.
installLongPressContextMenu();
// Every touch gesture in the app, installed once rather than per
// component (plan 019). Harmless on a desktop: it acts on
// `pointerType === 'touch'` only, per event, so a mouse on a
// touchscreen keeps click-selects / double-click-plays on the very
// same row. An unclaimed long press still becomes a `contextmenu`,
// which is what leaves all fourteen menus untouched by #63.
installTouchGestures();
// The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and
+5 -79
View File
@@ -23,97 +23,23 @@
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
if (opens > closes) problems.push({ file, line, tag });
}
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Fail on a nested rule whose selector starts with an element name.
*
* See `css-nesting.mjs` for what the phone does with one. No tier here
* can see it: the component tier, the e2e tier and `make ui-visual` all
* run a current Chromium, where the rule applies normally, so the only
* report is a screenshot of the device which is how the bottom bar's
* title came to have never truncated there.
*
* It covers `index.css` and the `css` literals in the components alike,
* because a shadow-root stylesheet is parsed by the same engine.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { findBareNestedRules } from './css-nesting.mjs';
const problems = [];
// Every stylesheet, not `index.css` by name: the hook that runs this
// fires on `frontend/**/*.{ts,css}`, so naming one file promises a
// coverage the sweep does not deliver -- a second stylesheet would be
// silently unswept while the hook still went green over it. There is
// only `index.css` today, which is exactly when this is free to fix.
const stylesheets = globSync('*.css', { cwd: process.cwd() });
if (stylesheets.length === 0) {
console.error('css-nesting-check: no stylesheet matched *.css');
process.exit(1);
}
for (const file of stylesheets) {
for (const { line, selector } of findBareNestedRules(
readFileSync(file, 'utf8'),
)) {
problems.push({ file, line, selector });
}
}
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
// A sweep over an empty glob passes, and this one is expected to find
// nothing, so "it found nothing" has to mean it looked.
if (sources.length === 0) {
console.error('css-nesting-check: no sources matched src/**/*.ts');
process.exit(1);
}
for (const file of sources) {
const src = readFileSync(file, 'utf8');
for (const literal of taggedLiterals(src, ['css'])) {
for (const { line, selector } of findBareNestedRules(literal.body)) {
problems.push({ file, line: literal.line + line - 1, selector });
}
}
}
if (problems.length > 0) {
for (const p of problems) {
console.error(
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
'with an element name — write it as "& ' +
`${p.selector.split('\n')[0]}"`,
);
}
console.error(
`\ncss-nesting-check: ${problems.length} problem(s). ` +
'Chrome 113 (the device) drops a nested rule that does not start ' +
'with a symbol; the leading & is valid in both syntaxes.',
);
process.exit(1);
}
console.log(
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
);
+103
View File
@@ -0,0 +1,103 @@
/**
* Finding the `css` tagged templates in a TypeScript source.
*
* Two checks read them the unterminated-comment one and the nesting
* one and a second scanner would be a second thing to keep in step
* with how a template literal actually ends.
*/
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
export function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/**
* Strip `${...}` substitutions, which may legitimately contain anything.
*
* Newlines inside them are kept, so a line number taken from the
* stripped text still names the right line of the file it came from.
*/
export function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
else if (text[i] === '\n') out += '\n';
}
return out;
}
/** The 1-based line number of `index` in `src`. */
export function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
/**
* Every tagged template literal in `src` whose tag is in `tags`.
*
* `body` has its substitutions stripped and `line` is the line its
* opening backtick sits on, so `line + (n - 1)` is the file line of the
* body's own line `n`.
*/
export function taggedLiterals(src, tags) {
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
const found = [];
let match;
while ((match = pattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
found.push({
tag: match[2],
body: stripSubstitutions(src.slice(open + 1, close)),
line: lineOf(src, open),
});
}
return found;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* A nested rule whose selector starts with an element name is silently
* dropped on the phone.
*
* The device renders in Chrome 113, which predates relaxed CSS nesting
* (Chrome 120): before that a nested selector had to start with
* something that could not be read as the beginning of a declaration,
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
* would notice the inner rule simply does not exist, on the phone and
* only on the phone. Three were live in `index.css`, one of them the
* `text-overflow: ellipsis` on the bottom bar's title, which had
* therefore never truncated on the device.
*
* `& audio-player` is valid in both syntaxes, so no nested rule here
* has any reason to omit it.
*
* Two things the detection has to get right:
*
* - **A rule directly inside an at-rule is not nested.**
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
* rule and is fine and it is the majority of the matches a regex
* over the file would produce. What decides it is whether a *style*
* rule is somewhere above, not what the immediate parent is: inside
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
* nested, at-rule in between or not.
* - **A declaration is not a rule.** `background: url(…)` and any
* string or comment can hold a brace, so this tracks them rather than
* matching lines.
*/
/** Does this selector start with an identifier, rather than a symbol? */
function startsWithIdent(selector) {
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
}
/**
* Every nested style rule in `css` whose selector starts with an
* element name, as `{ line, selector }` with a 1-based line.
*/
export function findBareNestedRules(css) {
const found = [];
/** The blocks we are inside, innermost last: 'style' or 'at'. */
const stack = [];
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
let prelude = '';
let preludeLine = 1;
let line = 1;
const startPrelude = () => {
prelude = '';
preludeLine = line;
};
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (c === '\n') {
line++;
if (prelude.trim() === '') preludeLine = line;
prelude += c;
continue;
}
if (c === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2);
const comment = css.slice(i, end === -1 ? css.length : end + 2);
line += (comment.match(/\n/g) ?? []).length;
i += comment.length - 1;
if (prelude.trim() === '') preludeLine = line;
continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < css.length && css[j] !== c) {
if (css[j] === '\\') j++;
j++;
}
prelude += css.slice(i, j + 1);
i = j;
continue;
}
if (c === '{') {
const selector = prelude.trim();
const kind = selector.startsWith('@') ? 'at' : 'style';
if (
kind === 'style' &&
stack.includes('style') &&
startsWithIdent(selector)
) {
found.push({ line: preludeLine, selector });
}
stack.push(kind);
startPrelude();
continue;
}
if (c === '}') {
stack.pop();
startPrelude();
continue;
}
if (c === ';') {
startPrelude();
continue;
}
prelude += c;
}
return found;
}
@@ -14,6 +14,7 @@ import {
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
@customElement('artist-details')
export class ArtistDetails extends LitElement {
@@ -40,7 +41,7 @@ export class ArtistDetails extends LitElement {
/** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null = null;
static override styles = [designTokens, css`
static override styles = [designTokens, backButton, css`
:host {
display: flex;
flex-direction: column;
@@ -66,31 +67,6 @@ export class ArtistDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -7,6 +7,7 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -30,6 +31,7 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { prefetchImageWindow } from '@utils/image-prefetch';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -582,6 +584,26 @@ export class ArtistsView
* Scroll position persistence
* ================================================================ */
/**
* Warm the avatars just past the rendered range (#65).
*
* `rangeChanged` is the rendered range and `visibilityChanged` is
* what is on screen; the virtualizer has already drawn about
* 1000px past the latter, so that is the wrong anchor to measure a
* prefetch window from. It is deliberately outside the
* `restoringScroll` guard below: a restored scroll lands in the
* middle of the grid, which is exactly when nothing around it is
* cached.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
prefetchImageWindow(
this.cachedGridEntries,
e.first,
e.last,
(entry) => this.artistAvatarURL(entry.artist),
);
};
/**
* Save the first visible item index on scroll.
*/
@@ -1145,7 +1167,16 @@ export class ArtistsView
* Helpers
* ================================================================ */
private renderArtistAvatar(artist: library.Artist) {
/**
* The image this artist's card will draw, or `''` for the initial
* placeholder.
*
* Split out of `renderArtistAvatar` so the prefetch (#65) asks for
* exactly what the card is going to ask for a second copy of the
* tier ladder would be a second thing to keep in step, and warming
* the wrong tier is a download that buys nothing.
*/
private artistAvatarURL(artist: library.Artist): string {
const needed = (this.imageSize ?? 176) * window.devicePixelRatio;
let imageURL = '';
@@ -1172,6 +1203,12 @@ export class ArtistsView
) ?? '';
}
return imageURL;
}
private renderArtistAvatar(artist: library.Artist) {
const imageURL = this.artistAvatarURL(artist);
if (imageURL) {
return html`<img
class="avatar-image"
@@ -1531,6 +1568,7 @@ export class ArtistsView
.keyFunction=${(entry: ArtistEntry) => entry.artist.ID}
.layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
</div>
${this.renderContextMenu()}
@@ -45,18 +45,6 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider {
--track-size: 6px;
flex: 1;
@@ -80,6 +68,57 @@ export class SeekBar extends LitElement {
background: var(--yj-bg-base, black);
}
/* The phone's seek bar, and this block is last on purpose.
A media query adds no specificity, so this lived above the plain
"wa-slider" rule and lost to it at every width: the 12px track it
asks for had never once applied, and the bar measured 261x6 on
the device while the source said 12. That is index.css's rule
("the phone section is last on purpose") met inside a component's
own stylesheet, and nothing renders differently in any tier here
to say so.
The bottom bar's seek bar is display:none below this width (016
B2 phase 1), so the only instance a viewport media query can
reach is the full-screen now-playing view's -- which is exactly
the one a thumb uses. The desktop bar keeps its 6px, where a
mouse is precise and the thickness is right.
The painted track and the thing you can hit are allowed to
differ, and a slider is the clearest case where they should: 12px
is a progress bar you can see, and 44px is the app's touch floor
(#56). A 44px-*thick* bar would be wrong-looking and would cost
the album art the vertical space #51 spent an issue recovering.
Two things about how the target is built.
The padding goes on ::part(slider) rather than on the host,
because that inner div is what carries the gesture -- it has the
listener and the touch-action: none, and it is exactly the host's
size, so padding the host would grow a box that does not take the
press.
The padding is asymmetric and the margins cancel it, so the row
does not grow by the difference. Both halves are measured: the
seek row is 19px (its clocks, not the track, decide that) and the
play button's top edge is 8px below it, so the target takes the
space *above*, where .art is a non-interactive div. Growing the
row instead cost the art 25px of 143. Verified on the device at
424x439: hit area 44px, painted track 12px, row still 19px, art
still 143px, 8px of clearance left under the play button, a press
26px above the track seeks, and a hit test on the play button's
top edge still reaches the play button. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
wa-slider::part(slider) {
padding-block: 28px 4px;
margin-block: -28px -4px;
}
}
#seek-bar-container {
display: flex;
justify-content: space-between;
@@ -250,13 +250,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
/* Collapsible-section toggle used in the Pending header
transparent button that inherits the header's type. */
transparent button that inherits the header's type.
187x**15** before this (#186), which was the smallest
control measured anywhere in the app until the column
arrows were counted. It is transparent and full-width
already, so the floor costs it a height and nothing
else. */
.section-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1;
min-width: 0;
min-block-size: 44px;
padding: 0;
background: transparent;
border: 0;
@@ -274,6 +281,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* 32x18, and it has no background until hover -- so the
padding out to a square target is invisible (#186). */
.folders-menu-trigger {
background: transparent;
border: 0;
@@ -281,6 +290,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.4rem;
min-inline-size: 44px;
min-block-size: 44px;
border-radius: 3px;
cursor: pointer;
}
@@ -293,7 +304,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
.folders-refresh-trigger {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
min-inline-size: 44px;
min-block-size: 44px;
}
.folders-refresh-trigger:disabled {
@@ -27,15 +27,43 @@ interface Tab {
* three to five items before the targets stop being thumb-sized
* 360 px over eleven sidebar entries is 32 px each so the four here
* are the ones plan 016's subset says a phone is *for*, and "More"
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
* opens the existing `<app-sidebar>` in a sheet. That is deliberately
* a reuse rather than a second nav: two lists of destinations is two
* places to add the next view to, and the sidebar already carries the
* drag-to-navigate behaviour, the active state and the labels.
*
* **"More" rises from the bottom, and it is the same sheet a context
* menu is** (#71). It was a `wa-drawer` sliding in from the side: a
* 200px column of a 424px screen, opening away from the thumb that
* asked for it, with three nested scrollers in it the dialog, its
* body, and the sidebar's own `overflow-y: auto` host which is the
* "only part of the screen scrolls under my finger" in the report.
*
* Three things about the replacement are load-bearing.
*
* **It is the same element with another `placement`, not a new
* surface.** `wa-drawer` renders a native `<dialog>` and opens it with
* `showModal()`, which is exactly what `menu-surface`'s sheet relies
* on Chrome 37, the real top layer so #60's containment finding
* carries over with nothing new to prove, and the focus trap, Escape,
* tap-outside and `wa-after-hide` all come along unchanged.
*
* **The body is the only scroller**, with `overscroll-behavior:
* contain`, and the sidebar is told to stop being one. Nesting them is
* what makes a drag scroll the wrong box.
*
* **The sidebar is still mounted rather than re-listed as data**,
* which the issue offers as an alternative. Its `data-testid` per
* destination is the reason: the shell's own sidebar is `display:
* none` below 600px rather than removed, so a second list drawing
* `nav-*` handles is the duplication this component already renders
* conditionally to avoid and it would be a second place to add the
* next view to, with its own copy of #25's visibility filter.
*
* It emits the same bubbling, composed `navigate` event the sidebar
* does, so `index.ts` needs no knowledge of it, and it listens for that
* event globally for the same reason the sidebar does: a navigation it
* did not send (a card click, a detail view, the drawer) still has to
* did not send (a card click, a detail view, the sheet) still has to
* move the highlight.
*/
@customElement('bottom-nav')
@@ -89,6 +117,17 @@ export class BottomNav extends LitElement {
color: var(--yj-accent, #ffd43b);
}
/* The press state (#54). This bar is the phone's primary
navigation and had no feedback of its own at all -- what a
tap produced was the web view's tap highlight, a grey box
over the whole 48px cell, which index.css has now taken
away. The .active rule above is which tab you are *on*; this
is the tab being pressed, so they are a colour and a
background rather than two colours. */
button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -2px;
@@ -104,15 +143,50 @@ export class BottomNav extends LitElement {
white-space: nowrap;
}
wa-drawer::part(body) {
padding: 0;
/* The sheet. --size is the drawer's own API for the axis its
placement uses, so auto is what makes it hug its content
instead of being a fixed 25rem band; the rest is the shape
the menu-surface context sheet already has, so a phone meets
one sheet rather than two. 85vh for its reason too: a surface
covering the whole screen is a page, not a sheet. */
wa-drawer {
--size: auto;
}
app-sidebar {
/* The sidebar sizes itself inline and collapses to icons
below 900px, which is every phone. In the drawer there
is room for the labels, so it is told not to. */
height: 100%;
wa-drawer::part(dialog) {
max-height: 85vh;
border-radius: 12px 12px 0 0;
/* The sidebar paints its own surface, so the sheet takes
that colour rather than the menus' elevated one: two
greys in one sheet is a seam across the middle of it. */
background-color: var(--yj-bg-surface, #212529);
/* One scroller, and it is the body below. The dialog's own
overflow: auto is what let the sheet scroll as well as
its content, and it is also what would square off the
corners this rule just rounded. */
overflow: hidden;
}
wa-drawer::part(body) {
padding: 0;
/* A scroll that reaches the end of this list must not
become a scroll of the page underneath it. */
overscroll-behavior: contain;
/* The sheet sits on the bottom edge, so the last
destination would otherwise be under the home indicator
on a gesture-navigation phone -- the same allowance the
bar itself makes above. */
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* A sheet is dragged at with a thumb, so it says where its top
edge is. Decorative: the destinations are below it. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
}
`];
@@ -147,7 +221,7 @@ export class BottomNav extends LitElement {
private visibilityCtrl = new ViewVisibilityController(this);
/**
* Whether the drawer has been asked for.
* Whether the sheet has been asked for.
*
* The sidebar inside it is rendered only while this is true, and
* that is not an optimisation. `app-sidebar` carries a
@@ -189,15 +263,17 @@ export class BottomNav extends LitElement {
override updated() {
// Web Awesome renders its heading into its own shadow root and
// never points aria-labelledby at it, so the drawer would
// never points aria-labelledby at it, so the sheet would
// otherwise be announced unnamed -- the same fix, and the same
// reason, as every wa-dialog in the app. A drawer's shadow root
// has the same shape, so the helper needs no change.
// has the same shape, so the helper needs no change; under
// `without-header` there is no heading to point at, which is
// that helper's documented `aria-label` path.
nameDialog(this.drawer);
}
private onGlobalNavigate = () => {
// A navigation from inside the drawer is the drawer's job done.
// A navigation from inside the sheet is the sheet's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -263,12 +339,14 @@ export class BottomNav extends LitElement {
</nav>
<wa-drawer
placement="start"
placement="bottom"
without-header
label="All views"
data-testid="nav-drawer"
?open=${this.drawerOpen}
@wa-after-hide=${this.onDrawerHide}
>
<div class="grip"></div>
${this.drawerOpen
? html`<app-sidebar expanded></app-sidebar>`
: nothing}
@@ -85,6 +85,21 @@ export class ConfigField extends LitElement {
gap: 0.5em;
}
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'],
input[type='number'] {
background: var(--yj-bg-elevated, #343a40);
@@ -95,6 +110,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
min-width: 0;
min-block-size: 44px;
flex: 1;
}
@@ -117,6 +133,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
font-family: inherit;
cursor: pointer;
min-block-size: 44px;
flex: 1;
}
@@ -139,6 +156,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em;
cursor: pointer;
white-space: nowrap;
min-block-size: 44px;
}
button:hover {
@@ -158,8 +176,12 @@ export class ConfigField extends LitElement {
}
input[type='color'] {
width: 2.5em;
height: 2.5em;
/* border-box, or the 2px border makes this 48 and the
assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
border: 2px solid var(--yj-border, #444);
border-radius: 4px;
padding: 0;
@@ -187,12 +209,32 @@ export class ConfigField extends LitElement {
display: flex;
align-items: center;
justify-content: space-between;
min-block-size: 44px;
}
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch {
position: relative;
width: 2.5em;
height: 1.4em;
display: grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
}
.toggle-switch input {
@@ -202,9 +244,10 @@ export class ConfigField extends LitElement {
}
.toggle-slider {
position: absolute;
position: relative;
cursor: pointer;
inset: 0;
inline-size: 2.5em;
block-size: 1.4em;
background: var(--yj-bg-overlay, #495057);
border-radius: 1em;
transition: background 0.2s;
@@ -176,6 +176,13 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
/* The app's 44px touch floor (#56, #186), stated once for
all 41 buttons this page renders rather than per class.
Height is free here: Settings has no overflow fit, so
the header's "only width is contested" rule does not
bind, and the two classes that need more than a height
say so below. */
min-block-size: 44px;
}
button:disabled {
@@ -509,11 +516,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
margin: 0;
}
/* The two column lists are the densest thing in the app, and
the density argument is why they are shaped the way they
are rather than simply grown (#186).
Measured on the reference device: the row was already
335x36 -- it is the controls *inside* it that were 16x16 and
**16x14**, the smallest anywhere in this app, 36 of them.
So the fix grows the controls into the row they already
occupy and only takes the row from 36 to 44, which over the
two lists (10 and 19 items) is 232px of extra scroll on a
439px screen. Growing each control to its own 44px row
instead would have cost four screens. */
.column-item {
display: flex;
align-items: center;
align-items: stretch;
gap: 0.5em;
padding: 0.5em 0.75em;
padding: 0 0.75em;
min-block-size: 44px;
border-bottom: 1px solid
var(--yj-border-subtle, #333);
font-size: 0.85em;
@@ -531,8 +551,19 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888);
}
/* A native checkbox cannot grow its hit area without growing
its paint, and a 44px checkbox is not what anyone wants. So
the target is the label instead: .column-label is a real
<label for> now, which makes the column's *name* the thing
you tap -- ~250x44 rather than 16x16.
That is the argument config-field already makes one file
over for its own labels: "a real label association also
makes the label text a click target for the control, which
is behaviour, not annotation". Here it is the whole fix. */
.column-toggle {
cursor: pointer;
align-self: center;
accent-color: var(
--yj-accent,
#ffd43b
@@ -541,20 +572,32 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.column-label {
flex: 1;
display: flex;
align-items: center;
cursor: pointer;
min-block-size: 44px;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
/* The row stretches its children so the label can be a
full-height target; this is text, not a target. */
align-self: center;
}
.column-arrows {
display: flex;
align-items: stretch;
gap: 0.15em;
margin-left: auto;
}
/* 16x14 before this, and they carry background: none and a
transparent border -- so padding out to 44px grows the
target and changes nothing anyone can see until hover,
which is precisely what #186's Direction asks for. */
.column-arrow-btn {
background: none;
border: 1px solid transparent;
@@ -564,6 +607,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
font-size: 0.65em;
line-height: 1;
padding: 0.2em 0.35em;
min-inline-size: 44px;
min-block-size: 44px;
transition:
color 0.15s,
border-color 0.15s;
@@ -711,6 +756,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
/* Square, so it needs the width too -- the shared rule
above only gives it a height. It was 31x31, and it is
the only route to "Remove library", which is the case
#55 settled one component over: the way out is 44px. */
min-inline-size: 44px;
}
.overflow-btn:hover {
@@ -2003,6 +2053,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="view-${v.id}"
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
@@ -2014,9 +2065,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
(e.target as HTMLInputElement).checked,
)}
/>
<span class="column-label">
<label class="column-label" for="view-${v.id}">
${v.label}
</span>
</label>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
@@ -2212,6 +2263,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
id="column-${id}"
type="checkbox"
class="column-toggle"
aria-label="Show the ${columnLabel} column"
@@ -2222,11 +2274,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
id,
)}
/>
<span
<label
class="column-label"
for="column-${id}"
>
${columnLabel}
</span>
</label>
<span
class="column-arrows"
>
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type {
DownloadDescriptor,
DownloadProvider,
@@ -136,6 +137,7 @@ export class DownloadClients extends LitElement {
static override styles = [
designTokens,
waTouchFloor,
css`
:host {
display: block;
@@ -239,12 +241,17 @@ export class DownloadClients extends LitElement {
margin-top: 0.4em;
}
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option {
display: flex;
align-items: center;
gap: 0.4em;
font-size: 0.9em;
cursor: pointer;
min-block-size: 44px;
}
`,
];
@@ -25,6 +25,11 @@ export class ShortcutCapture extends LitElement {
:host {
display: inline-block;
}
/* 80x25, twenty-six of them -- the most numerous control on
the Settings page after the column lists (#186). The floor
is a height here and nothing else: the width was already
past it, and the type stays where it is so a shortcut still
reads as a key rather than as a button. */
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
@@ -35,6 +40,7 @@ export class ShortcutCapture extends LitElement {
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
min-height: 44px;
text-align: center;
transition:
border-color 0.15s,
@@ -61,6 +67,11 @@ export class ShortcutCapture extends LitElement {
opacity: 0.7;
}
}
/* Reset renders only for a rebound shortcut, so a sweep of a
freshly-installed app never sees it -- it is not in #186's
tables for that reason, and it is a touch target the moment
anybody uses the feature. It also has no background, so the
padding out to 44px is invisible. */
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
@@ -69,7 +80,8 @@ export class ShortcutCapture extends LitElement {
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: auto;
min-width: 44px;
min-height: 44px;
opacity: 0;
transition: opacity 0.15s;
}
@@ -79,6 +91,17 @@ export class ShortcutCapture extends LitElement {
.reset-btn:hover {
color: var(--yj-accent-text, #ffd43b);
}
/*
* Reset is the only way to put a rebound shortcut back, so where
* the device has no hover it is always visible rather than an
* invisible button holding its hit area. The inverse of #68's
* rule, which applies where the hover control is redundant.
*/
@media not all and (hover: hover) {
.reset-btn {
opacity: 1;
}
}
`;
private handleClick = () => {
@@ -8,6 +8,7 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -30,6 +31,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import { prefetchImageWindow } from '@utils/image-prefetch.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js';
@@ -55,6 +57,8 @@ import {
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import type { GoToTarget } from '../../utils/go-to-menu';
import { creditStore } from '@store/credit-store';
import {
createAlbumArtDragImage,
@@ -908,6 +912,31 @@ export class CoverGrid
);
};
/**
* Warm the covers just past the rendered range (#65).
*
* `rangeChanged` rather than `visibilityChanged`, because the two
* report different ranges and only one of them is the right
* anchor: visibility is what is on screen, and the virtualizer has
* already rendered about 1000px past that. Measured from the
* visible range this would spend most of its window on cards that
* already exist and have already asked for their own art.
*
* The entry lists are memoized, so asking for one here costs a
* reference compare.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
const entries = this.splitMode
? this.getBeforeEntries()
: this.buildGridEntries();
prefetchImageWindow(entries, e.first, e.last, (entry) =>
entry.album.CoverArtPath
? this.getCoverUrl(entry.album)
: '',
);
};
/* ====================================================================
* Virtualizer items
* ==================================================================== */
@@ -2001,6 +2030,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
`;
}
@@ -2035,6 +2065,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
<album-dropdown
@@ -2084,6 +2115,30 @@ export class CoverGrid
);
}
/**
* The artist an album card's menu can navigate to — the card's own
* credit line, which stops being a link below the phone breakpoint
* (#67).
*
* A *track* target gets nothing: the dropdown's rows carry no
* links of their own, and the album they sit under is the card
* that opened them.
*/
private get goToTarget(): GoToTarget | undefined {
if (this.contextMenuTarget.kind !== 'album') return undefined;
const album = this.albums.find(
(a) => a.ID === this.contextMenuAlbumId,
);
if (!album) return undefined;
return {
artistName: album.ArtistName,
artistMBID: album.ArtistMBID,
};
}
private renderContextMenu() {
const { ctxMenu } = this;
@@ -2199,6 +2254,11 @@ export class CoverGrid
</wa-dropdown-item>
`
: nothing}
${goToMenuItems(this.goToTarget, {
onSelect: () => ctxMenu.close(),
onHover: () =>
ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -93,8 +93,26 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
}
/* 85x34 and 96x34 before this (#186). A tab is the only
route to the panel it names, so it is the last control
that should be hard to hit -- and the underline that
marks the active one is drawn on the bottom border,
which a taller box moves further from the label. So the
height goes on *padding*, keeping the border against
the label rather than 10px below a centred one.
The min-size is the floor and is not redundant: padding
alone made this 44px here and **43px in CI**, because
the total is 13 + 13 + 2 + whatever line box the font
gives 13px text, and ubuntu:24.04's is a pixel shorter
than this machine's. A height computed from a font's
line box is not a height you control -- the same
mistake #195 made about a layout property measured on
one engine, one layer down, and caught here by the test
rather than by a person. */
.tab {
padding: 8px 14px;
min-block-size: 44px;
padding: 13px 14px;
font-size: 13px;
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import {
@@ -355,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
srOnly,
@@ -379,25 +381,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -3208,10 +3191,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return html`
${artist
? html`<div class="album-artist">
<!-- keepOnPhone: the page header is not a row and
has no menu of its own, so this credit is the
only route from an album to its artist (#67). -->
${creditLink(
creditStore.credits(this.releaseGroupMBID),
artist,
artistMbid,
{ keepOnPhone: true },
)}
</div>`
: nothing}
@@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import {
LookupArtist,
BrowseReleaseGroups,
@@ -29,6 +30,7 @@ import { libraryStore } from '../../store/library-store';
import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import { describeError } from '../../utils/describe-error';
import {
GetAlbumsByArtist,
@@ -266,6 +268,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
@@ -289,25 +292,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon {
font-size: 16px;
}
@@ -2722,6 +2706,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
<!-- The track title links to its album, and below the phone
breakpoint it is plain text (#67). The artist is this
page, so there is nothing to go to. -->
${goToMenuItems(
{ albumName: track.releaseName, albumMBID: track.releaseGroupMbid ?? '' },
{
onSelect: () => this.ctxMenu.close(),
onHover: () => this.ctxMenu.closePlaylistSubmenu(),
},
)}
`;
}
@@ -23,6 +23,8 @@ import { queueStore } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import { creditLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { goToMenuItems } from '../../utils/go-to-menu';
import type { GoToTarget } from '../../utils/go-to-menu';
import { creditStore } from '@store/credit-store';
import { describeError } from '../../utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -54,10 +56,28 @@ export const ExploreRegion = 'explore';
* is present only when owned that's what gates the playback items,
* while `mbid` (always present) is what "View on MusicBrainz" uses, so
* a catalog-only card still gets a menu with somewhere useful to go.
*
* `goTo` is the names the card draws -- an artist credit, and for a
* recording row the release its title links to. Below the phone
* breakpoint those are plain text, so the menu is where they went
* (#67); an album card carries no album of its own, because tapping
* the card is already that.
*/
type ExploreMenuTarget =
| { kind: 'album'; mbid: string; localId?: number; title: string }
| { kind: 'recording'; mbid: string; localId?: number; title: string };
| {
kind: 'album';
mbid: string;
localId?: number;
title: string;
goTo?: GoToTarget;
}
| {
kind: 'recording';
mbid: string;
localId?: number;
title: string;
goTo?: GoToTarget;
};
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type LyricsResult = explore.LyricsResult;
@@ -256,15 +276,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
margin-bottom: 10px;
}
/* 89x26 and 79x26 before this (#186). */
.search-mode-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: none;
border: 1px solid transparent;
border-radius: 6px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-block-size: 44px;
padding: 5px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
@@ -289,7 +312,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
border-radius: 6px;
padding: 0 12px;
gap: 8px;
height: 36px;
min-height: 44px;
max-width: 520px;
transition: border-color 0.15s ease;
}
@@ -364,8 +387,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
flex-shrink: 0;
}
/* The input measured 325x**18** and the box around it 36,
which is two faults rather than one (#186): the row was
under the floor, and the input did not fill it, so eight
of those pixels were not a target at all. The container
is 44 and the input stretches to it -- a tap anywhere in
the box now lands on the input rather than beside it. */
input {
flex: 1;
align-self: stretch;
background: none;
border: none;
outline: none;
@@ -379,15 +409,21 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
color: var(--yj-text-tertiary, #888);
}
/* No background until hover, so the target grows and the
glyph does not. It is inside a 44px box already, hence
the width alone. */
.clear-button {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 0;
min-inline-size: 44px;
margin-inline-end: -12px;
font-size: var(--yj-text-sm);
flex-shrink: 0;
}
@@ -1370,6 +1406,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
${goToMenuItems(target.goTo, {
onSelect: () => this.ctxMenu.close(),
})}
</div>
`
: nothing}
@@ -2172,6 +2211,10 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
goTo: {
artistName: rg.artistCredit,
artistMBID: rg.artistMbid ?? '',
},
})}
role="button"
tabindex="0"
@@ -2184,6 +2227,10 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
goTo: {
artistName: rg.artistCredit,
artistMBID: rg.artistMbid ?? '',
},
},
)}
>
@@ -2262,6 +2309,12 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: r.mbid,
localId: r.localId,
title: r.title,
goTo: {
artistName: r.artistCredit,
artistMBID: r.artistMbid ?? '',
albumName: r.releaseName ?? '',
albumMBID: r.releaseGroupMbid ?? '',
},
})}
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
@@ -2272,6 +2325,12 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
mbid: r.mbid,
localId: r.localId,
title: r.title,
goTo: {
artistName: r.artistCredit,
artistMBID: r.artistMbid ?? '',
albumName: r.releaseName ?? '',
albumMBID: r.releaseGroupMbid ?? '',
},
},
)}
>
@@ -15,6 +15,7 @@ import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
@customElement('genre-details')
@@ -37,7 +38,7 @@ export class GenreDetails extends LitElement {
private scanCompleteCleanup: (() => void) | null =
null;
static override styles = [designTokens, css`
static override styles = [designTokens, backButton, css`
:host {
display: flex;
flex-direction: column;
@@ -77,31 +78,6 @@ export class GenreDetails extends LitElement {
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px; /* back button — outside type scale */
}
@@ -166,7 +166,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
* gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold
* that utils/long-press.ts is measuring for a context menu
* that utils/touch-gestures.ts is measuring for a long press
* a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with.
@@ -23,8 +23,14 @@ export class LibraryFilter extends LitElement {
align-items: center;
}
/* 120x32 on the reference device (#186). This control has two
placements since #57 -- the desktop top bar and Settings ->
Libraries -- and it is the only route to setSelectedLibrary
in either, so it is one of the controls #148 argued must not
simply be taken away. It is one component, so it reaches the
floor in one place. */
select {
height: 32px;
min-height: 44px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid
@@ -155,10 +155,55 @@ export class MenuSurface extends LitElement {
bottom was at y=452 on a 439px screen -- the one row a
destructive action is most likely to be. The cap has to stay
(a sheet covering the whole screen is a page, not a sheet),
so the body is what gives. */
so the body is what gives.
**And a body that scrolls says so** (#207). Scrolling was the
whole of the fix above, which left the last item reachable
and nothing on screen admitting it was there -- measured at
424x439, eight items ending at y=470 with the fold at 439,
and worse when the cut lands on a row boundary, where the
sheet ends in a clean edge that reads as the end of the list.
Two layers, and the *order* is what asks the question: a
shadow pinned to the bottom of the box (attachment scroll),
and over it a cover of the sheet's own colour painted at the
end of the *content* (attachment local), which therefore
scrolls up over the shadow and hides it exactly when there is
nothing more to see. So the affordance is absent on a menu
that fits, present the moment one does not, and gone again at
the end of the list -- with no scroll listener, no
measurement, and nothing reaching into wa-dialog's shadow
root for the scroller. background-attachment is Chrome 4;
the reference device is Chrome 113.
**The curve is steep because the rows under it stay live.**
A scrim over a menu item is that item's text surface, and
this app's rule is that text clears 4.5:1 on every surface it
can sit on -- which the light ramp, whose bgElevated is
#e9ecef, is what makes non-theoretical. A row is 48px with
its label centred, so 32px of scrim that is already down to
a quarter strength at 14px reaches y-centre at about 0.06 and
spends its weight on the strip below the last legible label.
Measured on the dark ramp at x=300, flat 52,58,64 throughout
before: 50,56,62 at y=330, 33,37,40 at y=350 and 22,24,27 at
the bottom edge, and flat again at the end of the list. The
light ramp puts 9.9:1 on the last label. */
wa-dialog::part(body) {
padding: 0;
overflow-y: auto;
background:
linear-gradient(
var(--yj-bg-elevated, #343a40),
var(--yj-bg-elevated, #343a40)
)
bottom / 100% 32px no-repeat local,
linear-gradient(
to top,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.25) 45%,
rgba(0, 0, 0, 0) 100%
)
bottom / 100% 32px no-repeat scroll;
}
/* A sheet is dragged at with a thumb, so it says where its top
@@ -105,6 +105,19 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
}
/* The art and the names are one block, so that a short
screen can lay them out side by side without either of them
knowing about the other's box. Vertically it is exactly what
the host used to do -- same gap, art flexible, names fixed --
so the tall layout is unchanged. */
.stack {
display: flex;
flex-direction: column;
gap: 0.75em;
flex: 1 1 auto;
min-height: 0;
}
.art {
flex: 1 1 auto;
display: flex;
@@ -113,38 +126,89 @@ export class NowPlayingView extends LitElement {
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone.
.art img {
/* Square, and never larger than the room left over --
where "square" is a property of what is painted and not
just of what was asked for.
**max-height is what actually keeps that promise**, and
it was missing. With a definite width and
a 1:1 aspect-ratio the height is *derived from the width*
and is bounded by nothing: at the reference device's
424x439 that is a 263px square (60vh) in a box with far
less than 263px left, so the art overflowed its own
centred flex item and drew over the header above and the
title below it. The comment claimed this was handled;
60vh is a bound on the *viewport*, not on the room left
over, and those differ by however much chrome is above
and below.
The previous rule asked for a square and did not get
one. width: min(100%, 60vh) makes the width definite,
aspect-ratio: 1 derives the height from it, and
max-height: 100% then clamps that height **without
re-deriving the width** -- which is how the
aspect-ratio property is specified to behave, unlike
an intrinsic ratio. So whenever the room left over was
shorter than the box was wide, the art was drawn as a
letterbox strip and object-fit: cover cropped the
cover to it. Measured on the reference device at
424x439: **264x53**, a 5:1 band of a square image.
Pre-existing -- screenshotted on main -- and made acute
by #56, which gives the transport 95px more than it had.
Found by reading a screenshot, which is the only tier
that can see it: nothing fails, nothing overflows the
*shell*, and every control is still hittable. */
width: min(100%, 60vh);
That is not only the phone. The leftover exceeds the
width only above ~843px of viewport, so every height
from ~500 to ~843 -- most phones, and any small window
-- drew a cropped strip too.
Both maxes with auto sizes is the fix, and it is the
replaced-element path rather than the aspect-ratio
one: the used size preserves the ratio under *both*
bounds (CSS2.1 10.4), so the art is square at every
height. Checked against Chrome 113 itself -- the
device's engine -- at column heights of 288, 300, 451,
600 and 800: square at all five, where the old rule
cropped at four.
A corollary worth knowing: auto will not upscale past
the image's natural size, and the largest tier
saveCoverArt keeps is 400px. Drawing it larger was
upscaling, so nothing is lost. */
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
/* The placeholder is not a replaced element, so it cannot use
the rule above: with no intrinsic size, auto/auto collapses
it to its icon -- measured at 13x58 in Chrome 113, which is
neither square nor the art's size.
So it is sized from the height, and then bounded by the
width in the one way a box like this can be. A non-replaced
element cannot express "the largest square that fits" in a
single rule: aspect-ratio derives the second axis from the
first, and whichever max clamps it does not re-derive the
other, which is the same trap the image rule above is about.
Driving it from the height alone is right until the column
is taller than it is wide -- ~843px of viewport, which is a
tall phone and #51's other named device -- and there it went
380x484.
max-height in viewport units is what closes it, and it is
sound here for the reason 60vh was not: this view is a
phone-width detail view, so its content box really is the
viewport less the host's own 1rem gutters. It is a *max*, so
the failure mode if that ever stopped being true is a square
bounded slightly early rather than a crop. rem and not em --
this box sets font-size: 3rem for the icon, so 2em here
would be 96px. */
.art .placeholder {
height: 100%;
width: auto;
max-width: 100%;
max-height: calc(100vw - 2rem);
/* A flex item's automatic minimum is its content, so
without this the icon's own width becomes a floor and
the box goes wider than it is tall the moment the row is
shorter than the icon -- which is exactly the state a
job band puts this screen in. */
min-width: 0;
aspect-ratio: 1;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
display: flex;
align-items: center;
justify-content: center;
@@ -232,6 +296,60 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
/* Below 500px of viewport the art and the names sit side by
side, and that is the whole of this screen's answer to a
short phone (#51).
The stacked layout cannot be rescued by sizing alone. Its
budget is fixed -- 48px of header, 143px of transport since
#64, 78px of names, 68px of padding and gaps -- so the art
gets height - 386, which on the reference device's 424x439
is **53px**. #172 measured 39px before #64 and named the
two options: give the art a floor and let the block scroll,
or reflow. A floor scrolls the transport off the bottom,
and "controls never scroll off" is #51's own Direction and
plan 018's promise -- so it is the reflow.
Sideways the art is bounded by the row's height rather than
by the column's leftover, which is the whole gain: the same
439px screen goes from a 53px sliver to **143px**, measured
on the device, with nothing scrolling and the transport
untouched.
500 is where the two layouts cross rather than a round
number. In a row the art is height - 296 and the names get
what is left of 392px, so the names hold 176px at exactly
500 and less above it; stacked, the art is height - 386,
which passes 176px at 562. Below 500 the row is the bigger
art *and* the readable one -- above it the column is, which
is why a tall phone (a Pixel 7's ~869) keeps the layout it
has. Unverified on that device: none was attached.
It is keyed on height alone, not on the phone's width,
because it is an answer to vertical room -- a 900x450 window
has the same problem and the same fix. */
@media (max-height: 500px) {
.stack {
flex-direction: row;
align-items: center;
}
/* A square of the row's height. The box has to carry the
ratio here rather than the image, because in a row the
art's width is what the ratio has to produce -- and the
image's own rule then fits it to a box that is already
square. */
.art {
flex: 0 1 auto;
height: 100%;
aspect-ratio: 1;
}
.meta {
flex: 1 1 auto;
}
}
`];
private back() {
@@ -283,55 +401,67 @@ export class NowPlayingView extends LitElement {
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
<div class="stack">
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<!-- keepOnPhone: this screen is the phone's,
and it has no context menu to carry the
destination the way a row does (#67).
Suppressing these takes the artist and the
album away rather than moving them, and
they are two lines of their own here
rather than a few characters inside a
row. -->
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
{ keepOnPhone: true },
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
{ keepOnPhone: true },
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
</div>
<div class="transport">
@@ -298,6 +298,47 @@ export class PageHeader extends LitElement {
flex-shrink: 0;
}
/* Every control in this header meets the app's 44px touch
floor -- the number #56 set for the transport and the
queue header already keeps (#186).
It is min-size rather than padding with a negative
margin, which is what the seek bar needed (#187), and
the difference is worth stating because it decides
whether targets can collide. There the painted track had
to stay thin, so the target was grown past its own box
and had to be checked against its neighbours. Here the
control *is* the target: the boxes are flex items, so
the gap keeps them apart and no two can overlap by
construction.
There is no phone branch. With the target being the box,
a 44px control on a desktop is merely large, and a
second declaration of what a phone shows is a second
thing to keep in step -- which is the reason this
component has never had one. It also avoids a media
query that no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.
**The height is the box and the width is not**, and that
asymmetry is the whole of what the overflow fit below
cares about. That pass measures inline size, so a taller
control costs it nothing and a wider one costs it
directly. Growing the two square controls to 44px wide
added 22px, which fits at every width Chromium was
checked at and clipped the overflow trigger at 320px in
**WebKit** -- the engine closest to what actually ships,
and the one no machine here can run. So the horizontal
half is padding with the margin cancelling it, which is
what the issue asked for in the first place: the target
grows and the layout does not.
The cost is that a horizontal target can now overlap a
neighbour, which the box version could not. The arrow's
is deliberately lopsided for the seek bar's reason
(#187): the select is 6px to its left and there is open
space to its right, so it takes the side with nothing to
steal from. */
.sort select {
font: inherit;
color: inherit;
@@ -306,6 +347,7 @@ export class PageHeader extends LitElement {
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
min-block-size: 44px;
}
.sort-dir {
@@ -318,6 +360,18 @@ export class PageHeader extends LitElement {
color: inherit;
cursor: pointer;
padding: 3px 5px;
/* 28x21 before this, the smallest control in the
header and the only one that failed the floor in
both directions.
Vertically the box grows, because the header has the
room and nothing measures it. Horizontally the box
must not: 28 + 2 + 14 is a 44px target over a 28px
layout box, weighted right because the select is 6px
to the left. */
min-block-size: 44px;
padding-inline: 5px 21px;
margin-inline: 0 -16px;
}
.sort-dir:hover {
@@ -377,10 +431,20 @@ export class PageHeader extends LitElement {
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
justify-content: center;
min-block-size: 44px;
}
.more-button {
padding: 6px 10px;
/* 38x27, and it is the route to every collapsed
action, so it is the last control that should be
hard to hit -- and the one WebKit clipped at 320px
when this was 6px wider as a box. 38 + 3 + 3 is a
44px target over a 38px layout box; the actions row
has an 8px gap, so this one can be symmetric. */
padding-inline: 13px;
margin-inline: -3px;
}
/* The display: flex above outranks the UA stylesheet's
@@ -38,6 +38,10 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
@@ -70,11 +74,17 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language';
/** One playlist row: the track and its position in the *playlist*,
@@ -421,6 +431,127 @@ export class PlaylistDetails
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
// =================================================================
// A finger on a playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/**
* A tap plays the playlist from that row.
*
* The same thing a double-click does, which is the rule the whole
* app follows: activating one row plays the list the row is in,
* from that row, rather than a queue of one that stops when the
* song ends.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap is left
// unclaimed and falls through to the click that selects it --
// which is what a mouse does here and the only useful thing a
// phantom row can answer.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
/**
* Swipe a row right to queue it.
*
* `track-list`'s rule, one list over: one row is a position and
* several rows are an explicit choice, and a swipe never changes
* the selection it reads.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
// A phantom has no file to queue, so there is nothing for
// the reveal to promise.
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${PlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu(
e: MouseEvent,
trackIndex: number,
@@ -460,6 +591,28 @@ export class PlaylistDetails
.map((i) => this.tracks[i]!.FilePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from one row
* or none, and only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.Artist,
artistMBID: track.ArtistMBID,
albumName: track.Album,
albumMBID: track.ReleaseGroupMBID,
};
}
// =================================================================
// Context menu actions
// =================================================================
@@ -954,8 +1107,11 @@ export class PlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -981,31 +1137,6 @@ export class PlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -1159,6 +1290,9 @@ export class PlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -1223,8 +1357,14 @@ export class PlaylistDetails
user-select: none;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -1244,11 +1384,13 @@ export class PlaylistDetails
cursor: pointer;
}
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
@media (hover: hover) and (pointer: fine) {
.track-item.phantom:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
}
.track-item.phantom.selected {
@@ -1258,6 +1400,19 @@ export class PlaylistDetails
);
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.phantom-row {
grid-column: 1 / -1;
display: flex;
@@ -1456,6 +1611,9 @@ export class PlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
class="track-scroller"
role="listbox"
@@ -1465,7 +1623,13 @@ export class PlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1487,6 +1651,7 @@ export class PlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1497,6 +1662,7 @@ export class PlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1543,6 +1709,7 @@ export class PlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div
class="phantom-row"
@@ -1778,6 +1945,14 @@ export class PlaylistDetails
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -13,6 +13,7 @@ import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { QueueController } from '@store/controllers/queue-controller';
import { PHONE_QUERY } from '@utils/breakpoints';
import { creditStore } from '@store/credit-store';
import {
describeQueueSource,
@@ -61,11 +62,18 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import {
ICON_NEW,
ICON_PLAY,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language';
import type { GestureEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
/** Above this many tracks, clearing the queue asks first. */
const CLEAR_CONFIRM_THRESHOLD = 20;
@@ -120,6 +128,25 @@ export class QueuePanel
@property({ type: Boolean, reflect: true })
overlay = false;
/**
* Phone width, from `matchMedia` rather than from a media query,
* because it decides whether the scrim *exists* (#171)
* `job-band`'s rule, and a stylesheet cannot express it: a
* `display: none` scrim is still an element with a click handler.
*
* Below 600px the panel spans the whole content area, so the scrim
* has no uncovered pixels: measured at 424x439, host, panel and
* scrim are all 424x318 with the scrim entirely underneath. It dims
* nothing and dismisses nothing there, and the queue is a *screen*
* at that width anyway (#55) back and a 44px close button are its
* ways out. Between 600 and 899 the panel is a 320px column of a
* wider content area, the scrim is reachable, and #24's
* tap-outside-to-close is real; that band is untouched.
*/
@state() private phone = false;
private phoneQuery?: MediaQueryList;
@state()
private isDragging = false;
@@ -391,6 +418,8 @@ export class QueuePanel
display: none;
}
/* Overlay only, and above 600px only -- see the phone field,
which is where that half is decided (#171). */
.scrim {
position: absolute;
inset: 0;
@@ -414,10 +443,10 @@ export class QueuePanel
/* A screen's way out has to be hittable with a thumb.
Measured at 424x439 before #55: these were **25x21px**,
and with the panel spanning the whole width the scrim
underneath has no uncovered pixels at all -- so it was
the only pointer route out of a full-screen surface.
Back answers it now as well, which is the other half.
and with the panel spanning the whole width there is no
scrim here at all (#171) -- so this is the only pointer
route out of a full-screen surface. Back answers it now
as well, which is the other half.
Sized only in overlay mode: inline these sit in a 320px
column beside the content, where a mouse is what reaches
@@ -552,8 +581,14 @@ export class QueuePanel
contain: strict;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -568,6 +603,19 @@ export class QueuePanel
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
.track-position {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #888);
@@ -639,23 +687,42 @@ export class QueuePanel
text-overflow: ellipsis;
}
/*
* The per-row remove is a hover affordance, and on a device
* without hover it is redundant rather than missing: the row's
* context menu is a bottom sheet since #60 and carries "Remove
* from Queue", so the action is one long-press away. An
* always-visible X would instead spend part of a 424px row on
* something already reachable. #68's treatment, for #68's reason.
*
* display:none outside the query rather than visibility:hidden:
* a hidden button still occupies its hit area and is still in
* the accessibility tree, so a phone would keep a target for a
* control it can never see.
*/
.remove-button {
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
display: none;
}
.track-item:hover .remove-button {
visibility: visible;
}
@media (hover: hover) and (pointer: fine) {
.remove-button {
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
.track-item:hover .remove-button {
visibility: visible;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
}
.list-area.drag-over {
@@ -803,6 +870,8 @@ export class QueuePanel
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
virtEl.addEventListener('yj-tap', this.onRowTap);
virtEl.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true;
}
@@ -828,6 +897,12 @@ export class QueuePanel
// desktop width rather than the minimum.
this.updateOverlayMode();
// Read here rather than in a field initialiser, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.phone = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneMedia);
if (this.parentElement) {
this.spaceObserver = new ResizeObserver(() =>
this.updateOverlayMode(),
@@ -870,6 +945,8 @@ export class QueuePanel
this.creditsUnsub = undefined;
this.spaceObserver?.disconnect();
this.spaceObserver = undefined;
this.phoneQuery?.removeEventListener('change', this.onPhoneMedia);
this.phoneQuery = undefined;
document.removeEventListener('keydown', this.onOverlayKeydown);
document.removeEventListener(
'mousemove',
@@ -913,6 +990,8 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
virtEl.removeEventListener('yj-tap', this.onRowTap);
virtEl.removeEventListener('yj-long-press', this.onRowLongPress);
}
this.delegationAttached = false;
}
@@ -936,6 +1015,10 @@ export class QueuePanel
this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR;
};
private onPhoneMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
/**
* Escape closes a scrimmed overlay, which is the one keyboard rule
* every dialog in this app already follows.
@@ -1194,6 +1277,92 @@ export class QueuePanel
this.queue.playAtIndex(index);
}
// =================================================================
// A finger on a queue row (plan 019 phase 3, #63)
// =================================================================
/**
* A tap plays this position in the queue.
*
* `track-list`'s tap sets the queue to the list it was made in;
* copying that here would rebuild the queue from the queue, which
* is not the no-op it looks like -- it would discard the queue's
* source, its shuffle order and everything a user had inserted by
* hand. `playAtIndex` is what a double-click already does, and it
* is what a tap means.
*/
private onRowTap = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
// A control inside the row owns its own tap -- the same rule
// the shortcut service has for a focused control that owns a
// key. The remove button is the one here.
if ((e.target as HTMLElement).closest('.remove-button')) return;
e.preventDefault();
// The roving tab stop follows the finger, or Tab returns to
// wherever the arrows last were rather than to the row that was
// just touched.
this.focusedIndex = idx;
if (this.selection.selectionMode) {
this.selection.toggleInMode(String(idx), idx);
this.virtualizer?.requestUpdate();
return;
}
this.selection.clear();
this.queue.playAtIndex(idx);
};
private onRowLongPress = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
e.preventDefault();
this.focusedIndex = idx;
this.selection.enterSelectionMode(String(idx), idx);
this.virtualizer?.requestUpdate();
};
/**
* The two worth a thumb, and "More" for the rest.
*
* Remove is here rather than left to the overflow because it is
* what a selection in a *queue* is most often made for, and it is
* the action the row's own × offers one row at a time.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${QueuePanel.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu(
e: MouseEvent,
index: number,
@@ -1476,6 +1645,29 @@ export class QueuePanel
.map((i) => tracks[i]!.filePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from, which is
* one row or none the rule the Play item already follows. Both
* items are drawn only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.queue.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.artist,
artistMBID: track.artistMbid,
albumName: track.album,
albumMBID: track.releaseGroupMbid,
};
}
// =================================================================
// Drop target (tracks dropped into queue)
// =================================================================
@@ -1972,7 +2164,7 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
${this.overlay
${this.overlay && !this.phone
? html`<div
class="scrim"
part="scrim"
@@ -2009,11 +2201,25 @@ export class QueuePanel
`
: nothing}
</div>
<!-- **Every action here is named by aria-label**, like
the close button #24 added beside them (#170). A
title alone *is* a name, which is why a sweep for
empty names reports these clean and why an
assertion by role and name is green either way --
but it is the weakest one: title is the last
fallback in the accname order, so any content put
inside the button later silently outranks it, and
a phone has no hover to show it as a tooltip.
The titles stay. On a desktop they are the tooltip
for an icon-only control, which is a different job
from naming it, and aria-label does not do it. -->
<div class="header-actions">
<button
class="header-action-button"
@click=${() => void this.handleClearQueue()}
?disabled=${tracks.length === 0}
aria-label="Clear queue"
title="Clear queue"
>
<wa-icon
@@ -2024,6 +2230,7 @@ export class QueuePanel
class="header-action-button add-to-playlist-button"
@click=${this.handleAddToPlaylist}
?disabled=${tracks.length === 0}
aria-label="Add queue to playlist"
title="Add queue to playlist"
>
<wa-icon
@@ -2104,6 +2311,7 @@ export class QueuePanel
></lit-virtualizer>
`}
</div>
${this.renderSelectionBar()}
</div>
<menu-surface
@@ -2192,6 +2400,14 @@ export class QueuePanel
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -61,11 +61,32 @@ export class SearchTrigger extends LitElement {
display: inline-flex;
align-items: center;
justify-content: center;
/* The smallest a touch target should be. The header's
own action buttons are smaller because they carry a
label; this one is a glyph. */
min-width: 40px;
min-height: 40px;
/* The app's touch floor, from #56 -- and this is the
control that should least have to argue for it: #57
created it as the phone's replacement for the header
search box, so it exists *only* where there is a
thumb.
It shipped at 40px under a comment calling that "the
smallest a touch target should be", which was the
floor being restated four pixels short rather than a
second opinion about it (#186). The rest of that
comment said the header's own action buttons are
smaller because they carry a label; they are 44px
now too, so that no longer distinguishes anything.
The extra width is a target rather than a box, for
page-header's reason: this button sits in that
header, whose overflow fit (#69) measures inline
size, and four pixels there is four pixels the
trigger for every collapsed action does not get at
320px. Height is free -- nothing measures it. */
min-width: 44px;
min-height: 44px;
/* Border-box, so the 44 above is the whole target and
the margin is what hands the four extra pixels back
to the row. */
margin-inline: -2px;
padding: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -0,0 +1,218 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { ICON_MORE_ACTIONS } from '@utils/icon-language';
/**
* What a selection can have done to it, while a finger is holding one.
*
* This is the context menu (plan 019, #63). Not a second surface
* beside it the same actions, contextualised to whatever is
* selected, in the shape Android puts them in.
*
* #63 asked for a double tap to open the menu instead. That mapping
* costs the app's primary interaction 250ms on every play, because the
* first tap of a double tap is indistinguishable from a single tap
* until the interval expires, and playing a track is ~100ms end to end
* on the reference device. So there is no double tap: a long press
* selects, this says what can be done, and the sheet behind "More" is
* the same `menu-surface` every other menu in the app opens.
*
* Four things about it are load-bearing.
*
* **It is presentational.** It takes a count and a list of actions and
* emits `selection-action` / `selection-exit`; it holds no selection
* and calls no store. The host already owns a `SelectionController`
* and an action handler, and a bar that reached for either would be a
* second definition of what "play the selection" means the fault
* `utils/library-status.ts` exists to have fixed one feature over.
*
* **The bar is only what fits, and "More" is the rest.** A context
* menu can be nine items because it is a sheet; a bar is one row on a
* 424px screen. So the host passes the two or three worth a thumb and
* the overflow opens the menu it already renders, which is what keeps
* every action reachable at every size plan 018's promise, and the
* reason this cannot simply drop the long tail.
*
* **Its controls are 44px** (#56, #186), and the count is a live
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **Escape leaves the mode, from here rather than from each host.**
* This element exists only while the mode does, so it is the one place
* a dismissal can be attached and detached with the thing it
* dismisses. It is the same exception the overlaid queue's Escape is.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected `SelectionController.toggleInMode` is where that is
* decided so a bar with a count of none is a state this should never
* be asked to draw, and drawing it anyway would hide the fact that it
* has been.
*/
export interface SelectionAction {
id: string;
label: string;
icon: string;
danger?: boolean;
}
@customElement('selection-bar')
export class SelectionBar extends LitElement {
static override styles = [
designTokens,
css`
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.25em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border-subtle, #333);
}
.count {
flex: 1;
min-width: 0;
font-size: var(--yj-text-md);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 44px, from #56 and #186 -- this is a bar a thumb uses. */
button {
display: flex;
align-items: center;
justify-content: center;
min-inline-size: 44px;
min-block-size: 44px;
padding: 0 0.5em;
border: none;
border-radius: 4px;
background: none;
color: var(--yj-text-primary, #fff);
font-family: inherit;
font-size: var(--yj-text-md);
cursor: pointer;
}
button:hover {
background: var(--yj-bg-overlay, #495057);
}
button.danger {
color: var(--yj-error-text, #ff8787);
}
`,
];
/** How many items are selected. Zero renders nothing. */
@property({ type: Number }) count = 0;
/** The actions worth a thumb. The rest live behind "More". */
@property({ attribute: false }) actions: SelectionAction[] = [];
private emit(name: string, detail?: unknown) {
this.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
}
/**
* Escape leaves the mode.
*
* A mode changes what a tap means, so it has to have an exit that
* is not "find the ×" -- and this is the documented exception to
* the app's one-keyboard-authority rule, on exactly the grounds
* the overlaid queue's Escape is: **it is a dismissal, not a
* shortcut**, so it is not a panel-scoped binding and it is
* attached only while there is something to dismiss. Putting it
* here rather than in each host is what gives all four surfaces
* the same answer, since this element exists only while the mode
* does.
*
* The platform's own back gesture is the other half of that and is
* deliberately *not* here: the shell owns the history stack
* (#6/#55), and a component reaching for `history` itself is how
* two stacks come to disagree about what one press means -- the
* fault that deleted `navStack`. See #200.
*/
private onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || this.count <= 0) return;
e.preventDefault();
e.stopPropagation();
this.emit('selection-exit');
};
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this.onKeydown, true);
}
override render() {
if (this.count <= 0) return nothing;
const noun = this.count === 1 ? 'track' : 'tracks';
return html`
<div class="bar" role="toolbar" aria-label="Selection actions">
<button
aria-label="Leave selection"
@click=${() => this.emit('selection-exit')}
>
<wa-icon name="xmark"></wa-icon>
</button>
<span class="count" role="status" aria-live="polite">
${this.count.toLocaleString()} ${noun} selected
</span>
${this.actions.map(
(action) => html`
<button
class=${action.danger ? 'danger' : ''}
aria-label=${action.label}
title=${action.label}
@click=${() =>
this.emit('selection-action', { id: action.id })}
>
<wa-icon name=${action.icon}></wa-icon>
</button>
`,
)}
<button
aria-label="More actions"
@click=${(e: MouseEvent) => {
const box = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
this.emit('selection-more', {
x: box.left,
y: box.top,
});
}}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'selection-bar': SelectionBar;
}
}
+73 -7
View File
@@ -42,6 +42,26 @@ export class AppSidebar extends LitElement {
scrollbar-width: thin;
}
/* A host that has made room owns the box, not just the labels
(#71). The bottom-nav sheet is the width of the screen and
provides the one scroll container it needs; left to itself
the sidebar is a 200px column with a second scroller inside
it, which is what a nested scroll region feels like under a
thumb -- part of the surface moves and part of it does not. */
:host([expanded]) {
max-width: none;
height: auto;
overflow: visible;
}
/* And the width is not draggable there. It is a mouse
affordance (mousedown, col-resize) sitting on the right edge
of a touch surface, where the compatibility mouse events a
tap synthesises can start a resize nobody asked for. */
:host([expanded]) .resize-handle {
display: none;
}
.resize-handle {
position: absolute;
top: 0;
@@ -95,8 +115,15 @@ export class AppSidebar extends LitElement {
text-align: center;
}
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
/* A hover tint is for a device that hovers (#54), and this
component is on a phone too: below 600px it is what
bottom-nav's "More" sheet mounts, where a hold
synthesises a hover and leaves a destination looking picked
after the finger has gone. */
@media (hover: hover) and (pointer: fine) {
li button:hover {
background-color: var(--yj-bg-elevated, #343a40);
}
}
li button:focus-visible {
@@ -108,6 +135,14 @@ export class AppSidebar extends LitElement {
background-color: var(--yj-bg-overlay, #495057);
}
/* The press state (#54), after the .active rule and at the same
specificity, so pressing the destination you are already on
still says something. It is what a tap gets now that
index.css has taken the web view's own highlight box away. */
li button:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
li button p {
margin: 0;
white-space: nowrap;
@@ -145,6 +180,25 @@ export class AppSidebar extends LitElement {
:host(.collapsed) li button wa-icon {
font-size: var(--yj-icon-md);
}
/* Below 600px the only place this renders is the bottom-nav
sheet -- the shell's own copy is display: none there -- so
the rows are sized for the thumb that opened it: 48px, which
is #186's floor and the height every row in #60's context
sheet already has. A media query inside a shadow root is
answered by the viewport, so the component states this
itself rather than the sheet reaching in. */
@media (max-width: 599px) {
ul {
padding: 4px 8px 8px;
}
li button {
min-height: 48px;
padding: 12px 10px;
gap: 14px;
}
}
`];
/** Delay in ms before a drag-hover triggers navigation. */
@@ -173,11 +227,14 @@ export class AppSidebar extends LitElement {
/**
* Keep the labels regardless of the viewport, for a host that has
* made room for them -- `bottom-nav`'s drawer, which is the whole
* made room for them -- `bottom-nav`'s sheet, which is the whole
* screen wide on the phone where this would otherwise auto-collapse
* to icons. The auto-collapse is a *width* response to a narrow
* shell, and inside a drawer the shell is not what the sidebar is
* shell, and inside a sheet the shell is not what the sidebar is
* sharing space with.
*
* It says the host owns the *box*, not only the labels: the width,
* the scrolling and the resize handle all follow it (#71).
*/
@property({ type: Boolean, reflect: true })
expanded = false;
@@ -350,9 +407,18 @@ export class AppSidebar extends LitElement {
* be a media query in the stylesheet.
*/
private applyViewportWidth() {
const narrow =
!this.expanded &&
(this.narrowViewport?.matches ?? false);
// A host that made room decides how much: `bottom-nav`'s sheet
// is the whole screen wide, and the inline width below -- which
// beats any rule the host could write -- would draw the old
// 200px side drawer inside it.
if (this.expanded) {
this.style.width = '100%';
this.collapsed = false;
return;
}
const narrow = this.narrowViewport?.matches ?? false;
const width = narrow
? MIN_WIDTH
: this.userWidth;
@@ -28,6 +28,10 @@ import {
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
setDragPayload,
@@ -60,10 +64,16 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { list } from '@utils/binding';
import {
ICON_PLAY,
ICON_PLAY_NEXT,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_SMART_PLAYLIST,
@@ -242,8 +252,11 @@ export class SmartPlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -269,31 +282,6 @@ export class SmartPlaylistDetails
);
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon {
font-size: 16px;
}
@@ -467,6 +455,9 @@ export class SmartPlaylistDetails
.track-item {
width: 100%;
box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
}
.track-header {
@@ -525,8 +516,14 @@ export class SmartPlaylistDetails
user-select: none;
}
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54). A hold
synthesises a hover in the WebView, so ungated this arrives
because a finger touched the row and stays after it has
gone; the press state below is what a tap gets instead. */
@media (hover: hover) and (pointer: fine) {
.track-item:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-item.selected {
@@ -542,6 +539,19 @@ export class SmartPlaylistDetails
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54): the feedback a tap has now that the
web view's own highlight box is gone (index.css). Last, and
carrying a class, because a selected or playing row is two
classes deep and a bare :active would lose to it. */
.track-item.selected:active,
.track-item.active:active,
.track-item:active {
background-color: var(
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
/* Phantom rows span the full grid */
.track-item.phantom {
display: grid;
@@ -939,10 +949,136 @@ export class SmartPlaylistDetails
.map((i) => this.tracks[i]!.FilePath);
}
/**
* The row "Go to Artist" / "Go to Album" navigate from one row
* or none, and only below the phone breakpoint, where the row's
* own names stopped being links (#67).
*/
private get goToTarget(): GoToTarget | undefined {
const indices = this.selection.getSelectedIndices();
if (indices.length !== 1) return undefined;
const track = this.tracks[indices[0]!];
if (!track) return undefined;
return {
artistName: track.Artist,
artistMBID: track.ArtistMBID,
albumName: track.Album,
albumMBID: track.ReleaseGroupMBID,
};
}
// =================================================================
// Context menu actions
// =================================================================
// =================================================================
// A finger on a smart playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/** A tap plays the playlist from that row -- `playlist-details`'
* rule, and the app's: activating a row plays the list it is in. */
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap falls through
// to the click that selects it.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${SmartPlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths();
@@ -1357,6 +1493,9 @@ export class SmartPlaylistDetails
<div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div>
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer
role="listbox"
aria-label="Smart playlist tracks"
@@ -1365,7 +1504,13 @@ export class SmartPlaylistDetails
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
.layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer>
${this.renderSelectionBar()}
`;
}
@@ -1387,6 +1532,7 @@ export class SmartPlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1397,6 +1543,7 @@ export class SmartPlaylistDetails
role="option"
aria-selected=${selected}
data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
@@ -1431,6 +1578,7 @@ export class SmartPlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div class="phantom-row">
<wa-icon
@@ -1578,6 +1726,14 @@ export class SmartPlaylistDetails
></wa-icon>
Track Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
@@ -371,8 +371,11 @@ export class TopResultsRow extends LitElement {
<span class="card-name">${r.name}</span>
${artistPart || metaPart
? html`<span class="card-subtitle"
>${artistPart
? creditLink(creditStore.credits(r.mbid), artistPart, r.artistMbid ?? '')
><!-- keepOnPhone: this card has no
context menu, so the credit is
the only route to the artist of
a top result (#67). -->${artistPart
? creditLink(creditStore.credits(r.mbid), artistPart, r.artistMbid ?? '', { keepOnPhone: true })
: nothing}${artistPart && metaPart
? ' · '
: ''}${metaPart}</span
@@ -529,6 +529,44 @@ export class TrackDetails extends LitElement {
background: var(--yj-error, #e03131);
}
/*
* Both are the *only* route to changing or removing a track's
* cover art, so where the device has no hover they are always
* visible rather than hidden the inverse of #68's rule, which
* applies where the hover control is redundant. Revealed by
* opacity, so what is on screen is what the desktop reveal shows
* and nothing about the layout moves.
*/
@media not all and (hover: hover) {
/* The × is genuinely the only route to removing the art, so
on a device that cannot hover it is simply always there.
The pen is not: .cover-art-edit carries the click that
opens the file picker, so tapping the artwork already
worked while the overlay was invisible. It is a discovery
hint and paying for discovery by covering the artwork
being edited in 50% black, permanently, on every touch
device, is heavier than the hint is worth. It becomes a
corner chip in the remove button's own visual language
instead: same size, same disc, same alpha. */
.cover-art-remove {
opacity: 1;
}
.cover-art-overlay {
opacity: 1;
inset: auto 4px 4px auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
}
.cover-art-overlay wa-icon {
font-size: 14px;
}
}
/* Error message */
.error-message {
flex: 1;
@@ -10,6 +10,10 @@ import {
} from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints';
import {
@@ -47,6 +51,8 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import type { GoToTarget } from '@utils/go-to-menu';
import {
setDragPayload,
emitDragActive,
@@ -77,7 +83,9 @@ import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
ICON_PLAY,
ICON_PLAYLIST,
ICON_PLAY_NEXT,
ICON_QUEUE,
} from '@utils/icon-language';
@@ -277,6 +285,29 @@ export class TrackList
* path into it at all (H-5). */
@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 => {
this.selection.selectAll();
};
@@ -1011,7 +1042,7 @@ export class TrackList
this.requestUpdate();
};
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css`
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, swipeRevealStyles, css`
:host {
display: flex;
flex-direction: column;
@@ -1170,8 +1201,16 @@ export class TrackList
padding-left: 6px;
}
.track-row:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
/* A hover tint is for a device that hovers (#54): a hold
synthesises a hover in the WebView, so ungated this is a
highlight that arrives because a finger touched the row and
then stays there after it has gone -- which reads as a
selection the user did not make. Same gate, and the same
mechanism, as #68's revealed controls. */
@media (hover: hover) and (pointer: fine) {
.track-row:hover {
background-color: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
}
.track-row.selected {
@@ -1208,6 +1247,24 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
/* The press state (#54), and the only feedback a tap has now that
the web view's tap highlight is gone (index.css).
**Last, and as specific as the state rules above**: a row that
is selected and playing is .track-row.selected.active, so a
bare .track-row:active is one class short of it and a press
on the row a phone is most likely to press -- the one it just
selected -- would show nothing. Instant rather than
transitioned, because the only measured statement here about
transitions on a list is that two card grids removed theirs
for software-rendering repaint cost. */
.track-row.selected:active,
.track-row.active:active,
.track-row:active {
background-color: var(--yj-press-overlay, rgba(255, 255, 255, 0.12));
}
.cell {
overflow: hidden;
text-overflow: ellipsis;
@@ -1304,6 +1361,11 @@ export class TrackList
virt.removeEventListener('click', this.onDelegatedClick);
virt.removeEventListener('dblclick', this.onDelegatedDblClick);
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
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('dragend', this.onTrackDragEnd);
}
@@ -1445,6 +1507,14 @@ export class TrackList
virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd);
// Delegated like the rest: the gesture layer dispatches on the
// element the finger landed on, composed, so it arrives here
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
virt.addEventListener('yj-swipe-start', this.swipe.onSwipeStart);
virt.addEventListener('yj-swipe-move', this.swipe.onSwipeMove);
virt.addEventListener('yj-swipe-end', this.swipe.onSwipeEnd);
this.delegationAttached = true;
}
@@ -1708,6 +1778,86 @@ export class TrackList
if (hit) this.onTrackContextMenu(e, hit.track);
};
/**
* A finger tapped a row (plan 019, #63).
*
* On a desktop a click selects and a double-click plays; a finger
* inverts that, because there is no second button and no modifier
* key, so the primary action has to be the primary gesture.
*
* Claiming the gesture (`preventDefault`) is what tells the layer
* to swallow the click behind it -- otherwise playing a track
* would also select it, and the row would end up in both states.
* A tap this does *not* claim falls through as an ordinary click,
* which is what keeps the favourite icon working.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) 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,
// and without it the 44px favourite target (#56) becomes a
// 44px play target.
if ((e.target as HTMLElement).closest('.fav-icon')) return;
e.preventDefault();
this.focusedIndex = hit.index;
if (this.selection.selectionMode) {
this.selection.toggleInMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
return;
}
this.playFromRow(hit.index);
};
/**
* A finger held a row still for half a second.
*
* Claiming this is what makes it *selection mode* rather than the
* context menu it has been since plan 016 -- an unclaimed
* `yj-long-press` still becomes a `contextmenu`, which is how the
* card grids and Explore keep the behaviour they have.
*/
private onRowLongPress = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(hit.track.FilePath, hit.index);
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) => {
const hit = this.resolveTrackFromEvent(e);
@@ -1831,6 +1981,33 @@ export class TrackList
emitDragActive(false);
};
/**
* The row the menu can navigate from, for "Go to Artist" / "Go to
* Album" which exist only below the phone breakpoint, where the
* row's own names are no longer links (#67).
*
* One row only, on the rule the Play item already states: one row
* is a position, several are an explicit choice of *those* tracks,
* and "go to the album" of five different albums means nothing.
*/
private get goToTarget(): GoToTarget | undefined {
if (this.selection.selectionCount !== 1) return undefined;
const [path] = this.selection.selectedItems;
const track = path
? tracksByFilePath(this.tracks).get(path)
: undefined;
if (!track) return undefined;
return {
artistName: track.ArtistName,
artistMBID: track.ArtistMBID,
albumName: track.Album,
albumMBID: track.ReleaseGroupMBID,
};
}
private onContextMenuAction(action: string) {
const filePaths =
this.selection.getSelectedKeysOrdered();
@@ -2129,6 +2306,7 @@ export class TrackList
'track-row': true,
active,
selected,
swiping: this.swipe.isSwiping(index),
})}
role="row"
aria-rowindex=${index + 1}
@@ -2138,8 +2316,10 @@ export class TrackList
draggable="true"
data-index=${index}
data-testid="track-row"
data-swipe
data-file-path=${track.FilePath}
>
${this.swipe.renderReveal(index)}
<div
role="gridcell"
class=${classMap({
@@ -2238,6 +2418,45 @@ export class TrackList
this.saveSortPreferences();
};
/**
* The three worth a thumb. Everything else is behind "More",
* which opens the context menu this list already renders.
*
* A bar is one row on a 424px screen and the menu is nine items,
* so this is a subset by necessity rather than a second opinion
* about what matters -- and plan 018's promise that no action is
* unreachable is kept by the overflow, not by this list.
*/
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() {
// Only in selection mode: a mouse selection is modeless and
// shows its actions on right-click, which is where a desktop
// user looks for them.
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${TrackList.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();
};
override render() {
const visibleTracks = this.cachedSortedTracks;
const cols = this.activeColumns;
@@ -2247,6 +2466,9 @@ export class TrackList
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus(visibleTracks.length)}
</div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
${this.tracks.length === 0
? this.renderPlaceholder()
: html`
@@ -2322,6 +2544,7 @@ export class TrackList
)}
</div>
</div>
${this.renderSelectionBar()}
`}
<menu-surface
@@ -2394,6 +2617,13 @@ export class TrackList
></wa-icon>
Track Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () => this.ctxMenu.closePlaylistSubmenu(),
})}
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
+15
View File
@@ -46,6 +46,17 @@ export interface ShadePalette {
border: string;
borderSubtle: string;
hoverOverlay: string;
/**
* The tint a surface takes while it is being pressed (#54).
*
* Separate from `hoverOverlay` because the two answer different
* questions and only one of them a phone can ask: a hover is a
* pointer resting somewhere, a press is a finger on the thing it
* is about to activate. It is deliberately the stronger of the
* two a press that reads the same as a hover says nothing on a
* device where the hover is synthesised by the press itself.
*/
pressOverlay: string;
selectionBg: string;
}
@@ -95,6 +106,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#333333',
borderSubtle: '#222222',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
pressOverlay: 'rgba(255, 255, 255, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
dark: {
@@ -114,6 +126,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#444444',
borderSubtle: '#333333',
hoverOverlay: 'rgba(255, 255, 255, 0.05)',
pressOverlay: 'rgba(255, 255, 255, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
light: {
@@ -133,6 +146,7 @@ export const SHADE_PALETTES: Record<BackgroundShade, ShadePalette> = {
border: '#ced4da',
borderSubtle: '#dee2e6',
hoverOverlay: 'rgba(0, 0, 0, 0.05)',
pressOverlay: 'rgba(0, 0, 0, 0.12)',
selectionBg: 'rgba(100, 160, 255, 0.15)',
},
};
@@ -285,6 +299,7 @@ function deriveThemeVariables(
// Interactive overlays
'--yj-hover-overlay': palette.hoverOverlay,
'--yj-press-overlay': palette.pressOverlay,
'--yj-selection-bg': palette.selectionBg,
// Semantic *fills* — the background of a solid button or badge.
+54
View File
@@ -0,0 +1,54 @@
import { css } from 'lit';
/**
* The way out of a detail view, at the app's 44px touch floor.
*
* #186's second table names `artist-details`' back button at
* **32x32**. It is the same declaration in **six** components
* `artist-details`, `genre-details`, `playlist-details`,
* `smart-playlist-details`, `explore-artist-details` and
* `explore-album-details` byte-identical, 32px in all six, and the
* sweep that filed the issue visited one of them.
*
* That is the argument for this file rather than six edits. A device
* sweep walks the views somebody thought to open, so six copies of a
* control is six chances for the next pass to miss five; the arrows
* and the toggles were each one declaration covering 36 and 29
* controls, and this is the same shape stated the other way round.
*
* **It is a real 44px box, not padding with the width handed back.**
* The header pass had to grow a hit area past its own layout box
* because `page-header` measures itself for #69's overflow fit; a
* detail view's header does not, so the control can simply be the
* target. It also *should* be this button has a visible background,
* so a hit area larger than the circle would be a control that is
* bigger than it looks, which is the thing #187 accepts only where a
* thin painted track is the point.
*
* The size is #55's, arrived at for the same reason one component
* over: "the way out is 44px on a phone", when the queue panel's close
* button was 25x21 and, at phone width, the only pointer route off a
* full-screen surface. A detail view has the platform's back gesture
* as well, so this is less severe than the queue was it is the same
* control wearing the same mistake.
*/
export const backButton = css`
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
`;
+48
View File
@@ -0,0 +1,48 @@
import { css } from 'lit';
/**
* A Web Awesome form control is at least the app's 44px touch floor.
*
* #56 named 44px and #186 found nothing but the transport had reached
* it. Web Awesome's form controls are the part of Settings this app
* does not draw: measured on the reference device (TLP301, 424x439),
* `wa-input`'s control is **204x20** and `wa-button` **185x21** the
* shortest controls on the page, and the only ones whose height is
* decided inside somebody else's shadow root.
*
* `--wa-form-control-height` is that decision, and it is the library's
* own theming variable rather than a part or an internal the default
* theme sets it at `:root` and every control that has a height reads
* it (button, input, select, radio). So this is `wa-slider-label`'s
* better half: the API first, and no reach into a shadow root at all.
*
* Three things about it are load-bearing.
*
* **A custom property inherits through a shadow boundary**, which is
* what lets a `:host` declaration reach a `wa-input` the host renders.
* That is also why it is a stylesheet a component adopts rather than a
* `:root` rule in `index.css`: a `:root` rule would cover every wa
* control in the app in one line and be invisible to the component
* tier, which renders a component and no page stylesheet. Here the
* floor is measurable where it is applied.
*
* **It is a flat 44px rather than a floor over the library's own
* expression.** The default is `round(calc(2 * padding-block + 1em *
* line-height), 1px)` — em-based, so `size="small"` is what produced
* the 20px above and a `max(44px, …)` would have to restate that
* formula here, which is a copy of somebody else's arithmetic that
* goes stale silently. A flat value is safe because this app uses
* exactly two sizes, `small` and the default, and both are under the
* floor; a `size="large"` added later would be pinned down to 44 and
* should take that as the prompt to revisit this.
*
* **Only the height is pinned.** The font size still comes from
* `size="small"`, so a control grows its hit area without growing its
* visual weight which is what #186's Direction asks for and what the
* page header's second pass had to be corrected to do.
*/
export const waTouchFloor = css`
:host {
--wa-form-control-height: 44px;
}
`;
+28 -3
View File
@@ -710,10 +710,35 @@ export const contextMenuStyles = css`
font-size: 13px;
}
.context-menu-panel wa-dropdown-item:hover {
/* A hover tint is for a device that hovers (#54).
Below the query is a phone, where a hold *synthesises* a hover
in the WebView -- the same mechanism #68 gates the revealed
controls on -- so an ungated tint is a highlight that arrives
because a finger touched the row and then stays on it after the
finger has gone. Which is indistinguishable from the press
state below, and outlives it. */
@media (hover: hover) and (pointer: fine) {
.context-menu-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
}
/* And a press state is for every device, because it is the one
piece of feedback a tap has now that the web view's own
highlight box is gone (index.css). Stronger than the hover tint
on purpose, and instant rather than transitioned: the only
measured statement this repo has about transitions on these
surfaces is the two card grids that removed theirs because
software rendering repaints per frame, and the phone is not
something this session can measure. */
.context-menu-panel wa-dropdown-item:active {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
--yj-press-overlay,
rgba(255, 255, 255, 0.12)
);
}
+135 -29
View File
@@ -13,11 +13,35 @@
* bug, not as a statement about metadata. The only case that still
* renders as text is one we genuinely cannot route (no name at all, or
* nothing in the library by that name).
*
* ## Below the phone breakpoint a name is not a link (#67)
*
* A few characters of text inside a row is not a touch target, and the
* click handling below is explicitly a *desktop* compromise: the
* navigation is held for one double-click interval so double-clicking
* the row can still play it, which means nothing at all on touch. On
* a phone the row's own gesture wins anyway a claimed `yj-tap` has
* its click swallowed by `utils/touch-gestures.ts`, so the link was
* unreachable as well as fiddly.
*
* So the rule lives here rather than at twenty call sites, which is
* what the Findings on #67 ask for: a name renders as plain text below
* `PHONE_QUERY`, and the row's context menu carries "Go to Artist" /
* "Go to Album" in its place (`goToMenuItems`).
*
* The exception is `keepOnPhone`, and it is not a preference. Three
* surfaces render a name with **no menu to carry the destination**
* `now-playing-view`, `explore-album-details`' header credit and
* `top-results-row` so suppressing the link there takes the action
* away entirely rather than moving it, which is what plan 018's "no
* action is unreachable at any supported size" refuses. Each of those
* call sites says so.
*/
import { html, css } from 'lit';
import type { TemplateResult } from 'lit';
import { libraryStore } from '../store/library-store';
import { PHONE_QUERY } from './breakpoints';
/** Shared CSS for explore link styling. Import into component styles. */
export const exploreLinkStyles = css`
@@ -32,6 +56,57 @@ export const exploreLinkStyles = css`
}
`;
/**
* Options every link function takes, for the one case that is not the
* default.
*/
export interface LinkOptions {
/**
* Keep the name navigable at phone width.
*
* For a surface with no context menu to carry the destination
* see the header of this file. A row must not pass it: the row's
* tap already means "play", and the menu is where the destination
* went.
*/
keepOnPhone?: boolean;
}
/**
* The live phone breakpoint, made once and read per link.
*
* A `MediaQueryList` is live, so one object answers for the life of
* the page and a resize needs nothing from here. The identity check
* is the test seam: this tier's viewport is fixed by the runner, so a
* spec answers the query by replacing `window.matchMedia` (the same
* stub `now-playing-phone.test.ts` installs), and swapping the
* function is what tells us to ask again.
*/
let phoneQuery: MediaQueryList | undefined;
let phoneQuerySource: typeof window.matchMedia | undefined;
/**
* Whether an inline name still navigates.
*
* Exported because the menus that carry the destination in its place
* are drawn under exactly the same condition -- one answer, not two.
*/
export function inlineLinksSuppressed(): boolean {
if (!window.matchMedia) return false;
if (phoneQuerySource !== window.matchMedia) {
phoneQuerySource = window.matchMedia;
phoneQuery = window.matchMedia(PHONE_QUERY);
}
return phoneQuery?.matches ?? false;
}
/** Whether this call site should render plain text rather than a link. */
function plainText(options?: LinkOptions): boolean {
return !options?.keepOnPhone && inlineLinksSuppressed();
}
/** Fire a navigate event from the clicked element. */
function navigate(target: EventTarget, detail: Record<string, unknown>): void {
target.dispatchEvent(
@@ -153,39 +228,19 @@ function singleClick(
* @param mbid - The MusicBrainz artist ID. Empty string = local only.
* @param content - Optional custom content to render inside the link
* (e.g. highlighted search result). Defaults to artistName.
* @param options - See `LinkOptions`.
*/
export function artistLink(
artistName: string,
mbid: string,
content?: TemplateResult | string,
options?: LinkOptions,
): TemplateResult | string {
if (!artistName) return artistName;
if (plainText(options)) return content ?? artistName;
const onClick = singleClick((target) => {
void (async () => {
if (mbid) {
navigate(target, {
view: 'explore-artist-details',
artistMBID: mbid,
artistName,
});
return;
}
const local = await findLocalArtist(artistName);
if (!local) return;
// The caller's row had no MBID, but the library row for the
// same artist may — the grid routes by exactly this field,
// so reading it here is what keeps the two paths agreeing.
navigate(target, {
view: 'explore-artist-details',
artistMBID: local.MBID || '',
artistName,
localArtistId: local.ID,
});
})();
void openArtistPage(target, artistName, mbid);
});
return html`<a
@@ -203,19 +258,22 @@ export function artistLink(
* @param mbid - The MusicBrainz release group ID. Empty = local only.
* @param content - Optional custom content to render inside the link.
* @param artistName - Disambiguates same-named albums in the library.
* @param options - See `LinkOptions`.
*/
export function albumLink(
albumName: string,
mbid: string,
content?: TemplateResult | string,
artistName?: string,
options?: LinkOptions,
): TemplateResult | string {
if (!albumName) return albumName;
if (plainText(options)) return content ?? albumName;
return html`<a
class="explore-link"
@click=${singleClick((target) => {
void openAlbum(target, albumName, mbid, artistName);
void openAlbumPage(target, albumName, mbid, artistName);
})}
title=${mbid ? 'View album on Explore' : 'View album in your library'}
>${content ?? albumName}</a>`;
@@ -232,6 +290,7 @@ export function albumLink(
* @param recordingMBID - The track's MusicBrainz recording ID.
* @param content - Optional custom content (e.g. highlighted text).
* @param artistName - Disambiguates same-named albums in the library.
* @param options - See `LinkOptions`.
*/
export function trackLink(
trackName: string,
@@ -240,14 +299,16 @@ export function trackLink(
recordingMBID: string,
content?: TemplateResult | string,
artistName?: string,
options?: LinkOptions,
): TemplateResult | string {
if (!trackName) return trackName;
if (!albumName) return content ?? trackName;
if (plainText(options)) return content ?? trackName;
return html`<a
class="explore-link"
@click=${singleClick((target) => {
void openAlbum(
void openAlbumPage(
target,
albumName,
releaseGroupMBID,
@@ -262,11 +323,48 @@ export function trackLink(
>${content ?? trackName}</a>`;
}
/**
* Route to an artist page, preferring the catalog and falling back to
* the library copy.
*
* Exported because a menu item goes to the same place a name does, and
* two routings of "go to this artist" is how the two come to disagree
* about an untagged one.
*/
export async function openArtistPage(
target: EventTarget,
artistName: string,
mbid: string,
): Promise<void> {
if (mbid) {
navigate(target, {
view: 'explore-artist-details',
artistMBID: mbid,
artistName,
});
return;
}
const local = await findLocalArtist(artistName);
if (!local) return;
// The caller's row had no MBID, but the library row for the
// same artist may — the grid routes by exactly this field,
// so reading it here is what keeps the two paths agreeing.
navigate(target, {
view: 'explore-artist-details',
artistMBID: local.MBID || '',
artistName,
localArtistId: local.ID,
});
}
/**
* Route to an album page, preferring the catalog and falling back to
* the library copy. `highlight*` marks one track on arrival.
*/
async function openAlbum(
export async function openAlbumPage(
target: EventTarget,
albumName: string,
releaseGroupMBID: string,
@@ -337,23 +435,31 @@ export interface CreditPart {
* @param parts - The credit's parts in position order, if known.
* @param fallbackName - The credit as a single string.
* @param fallbackMbid - The primary artist's MBID.
* @param options - See `LinkOptions`.
*/
export function creditLink(
parts: readonly CreditPart[] | undefined,
fallbackName: string,
fallbackMbid: string,
options?: LinkOptions,
): TemplateResult | string {
// One part is one link, so it is the fallback rather than a special
// case — and a zero-part credit reaching here would otherwise
// render as nothing at all, which is worse than the single-artist
// answer it replaced.
if (!parts || parts.length < 2) {
return artistLink(fallbackName, fallbackMbid);
return artistLink(fallbackName, fallbackMbid, undefined, options);
}
// A decomposed credit is rendered from the same parts either way,
// so the join phrases survive the suppression and the text reads
// as it did — which is `creditText`'s job, and it is the string
// the `title=` beside these already uses.
if (plainText(options)) return creditText(parts, fallbackName);
return html`${parts.map(
(part) =>
html`${artistLink(part.creditedName, part.artistMbid)}${part.joinPhrase}`,
html`${artistLink(part.creditedName, part.artistMbid, undefined, options)}${part.joinPhrase}`,
)}`;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* "Go to Artist" / "Go to Album", for the menus that carry a name the
* phone stopped drawing as a link (#67).
*
* `utils/explore-link.ts` renders a plain string below the phone
* breakpoint, because a few characters inside a row is not a touch
* target and the row's own tap already means "play". That takes a
* destination away, so the row's context menu gives it back which is
* the whole of this issue: the navigation moves, it does not go.
*
* Three things about it are load-bearing.
*
* **It is drawn under exactly the condition the link is not.**
* `inlineLinksSuppressed()` answers both, so a desktop menu is
* untouched (the name beside it is still a link, and a menu that
* repeats what the row already offers is furniture) and a phone menu
* cannot be missing what the row lost.
*
* **It goes where the name went.** `openArtistPage` / `openAlbumPage`
* are `explore-link`'s own routing, exported rather than reimplemented,
* so an untagged artist reaches the library page here for the same
* reason and by the same lookup it does from a link.
*
* **The host says when it is over**, through `onSelect` every menu in
* this app closes itself and most clear their selection, and both are
* the host's bookkeeping rather than something a shared item may do on
* its behalf. `onHover` is for the four hosts with a playlist submenu,
* which closes on any other item being pointed at.
*/
import { html, nothing } from 'lit';
import type { TemplateResult } from 'lit';
import { inlineLinksSuppressed, openArtistPage, openAlbumPage } from './explore-link';
/**
* The entities one row or card can send you to.
*
* Everything is optional because the hosts differ: a track row knows
* both, an album card knows only its artist, and an artist page's own
* tracklist knows only the album.
*/
export interface GoToTarget {
artistName?: string;
artistMBID?: string;
albumName?: string;
albumMBID?: string;
}
export interface GoToHandlers {
/** Called before navigating: close the menu, clear the selection. */
onSelect?: () => void;
/** Called on hover: close a playlist submenu, where the host has one. */
onHover?: () => void;
}
/**
* The menu items for a target, or nothing at all where the name beside
* them is still a link.
*/
export function goToMenuItems(
target: GoToTarget | undefined,
handlers: GoToHandlers = {},
): TemplateResult | typeof nothing {
if (!target || !inlineLinksSuppressed()) return nothing;
const artist = target.artistName?.trim();
const album = target.albumName?.trim();
if (!artist && !album) return nothing;
return html`
${artist
? html`<wa-dropdown-item
data-testid="go-to-artist"
@click=${(e: Event) => {
handlers.onSelect?.();
void openArtistPage(
e.currentTarget as EventTarget,
artist,
target.artistMBID ?? '',
);
}}
@mouseenter=${() => handlers.onHover?.()}
>
<wa-icon slot="icon" name="user-group"></wa-icon>
Go to Artist
</wa-dropdown-item>`
: nothing}
${album
? html`<wa-dropdown-item
data-testid="go-to-album"
@click=${(e: Event) => {
handlers.onSelect?.();
void openAlbumPage(
e.currentTarget as EventTarget,
album,
target.albumMBID ?? '',
artist,
);
}}
@mouseenter=${() => handlers.onHover?.()}
>
<wa-icon slot="icon" name="compact-disc"></wa-icon>
Go to Album
</wa-dropdown-item>`
: nothing}
`;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Warm the browser's image cache for the cards a scroll is about to
* reach.
*
* #65: album art pops in while scrolling. The rule this app already
* follows is that a row image is `loading="lazy" decoding="async"` and
* draws the smallest adequate tier, and both halves are in place
* `cover-grid.getCoverUrl()` and `artists-view`'s avatar both pick
* `_sm`/`_md`/`_lg` from the card size and the device pixel ratio. What
* is left is *when* the fetch starts: the grids are virtualized, so the
* `<img>` does not exist at all until the virtualizer decides to render
* its card, and only then can the browser ask for anything.
*
* The issue's Direction asks for a larger overscan, and that is not
* available: `@lit-labs/virtualizer`'s `_overhang` is a hard-coded
* 1000px `protected` field on `BaseLayout` with no configuration
* surface, so raising it means monkey-patching a private. 1000px is
* about two screens on the reference device's 439px viewport, which is
* a fraction of a second at speed.
*
* So the request is issued ahead of the element instead. Cover art and
* artist images are plain URLs served by `coverart.Handler` /
* `explore`'s image handler under `Cache-Control: public,
* max-age=31536000, immutable` — the filenames are content hashes — so
* a prefetched image is a cache hit by the time the card is drawn, and
* a second pass over the same rows costs nothing at all.
*
* Three things about it are load-bearing.
*
* **This is not the `LRUMap` path the issue's Findings warn about.**
* That ceiling (`ARTIST_IMAGE_CACHE_LIMIT` and friends) bounds
* Explore's base64 data URLs, which are held in JS. A library cover is
* a URL, and what retains the bytes is the browser's own HTTP cache,
* which evicts on its own terms. What this module retains is the *set
* of URLs already asked for*, which is why that set has a cap and
* reports itself to `window.__yjCacheStats()` the measurement the
* issue asks for.
*
* **A window is warmed on both sides of the rendered range.** The
* event carries no direction, and scrolling back up needs the same
* treatment; the rows behind are already in `requested` from the pass
* that rendered them, so the backward half issues nothing in the
* common case and is free.
*
* **An in-flight image is held.** `new Image().src = url` and drop it
* is the usual idiom and usually survives, but "usually" is an engine
* detail and the engine that matters here is a two-year-old WebView.
* The element is kept until it loads or fails, and no longer nothing
* here holds a decoded bitmap on purpose.
*/
import { registerCacheProbe } from './cache-stats.js';
import { LRUMap } from './lru-map.js';
/**
* How many entries past each edge of the rendered range to warm.
*
* Entries rather than pixels, because that is what the event reports
* and what the caller has an array of. Twelve rows on the phone's
* two-column grid and four on a desktop's six, on top of the
* virtualizer's own 1000px enough to cover a flick, and bounded so a
* fast scroll through 5 000 albums cannot ask for 5 000 covers.
*/
export const PREFETCH_AHEAD = 24;
/** Ceiling on the record of what has already been asked for. */
export const PREFETCH_MEMORY = 512;
/** URLs already requested; the value is a placeholder, the key is the record. */
const requested = new LRUMap<string, true>(PREFETCH_MEMORY);
/** Images still loading, held so the request cannot be collected. */
const inFlight = new Set<HTMLImageElement>();
registerCacheProbe('imagePrefetch', () => {
let chars = 0;
for (const url of requested.keys()) chars += url.length;
return { entries: requested.size, chars, limit: PREFETCH_MEMORY };
});
/** Whether this URL has already been asked for. */
export function imagePrefetched(url: string): boolean {
return requested.has(url);
}
/**
* Ask the browser for `url` unless it has already been asked for.
* Returns whether a request was issued.
*/
export function prefetchImage(url: string): boolean {
if (!url || requested.has(url)) return false;
requested.set(url, true);
const img = new Image();
inFlight.add(img);
const done = () => {
inFlight.delete(img);
};
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
img.decoding = 'async';
img.src = url;
return true;
}
/**
* Warm the images either side of a virtualizer's rendered range.
*
* `first`/`last` are the indices the `visibilityChanged` event
* reported; `urlOf` returns the image the card at that index will
* draw, or `''` where it draws a placeholder. Returns how many
* requests were issued, which is what a test can assert on and what
* makes "a second run does approximately nothing" checkable.
*/
export function prefetchImageWindow<T>(
items: readonly T[],
first: number,
last: number,
urlOf: (item: T) => string,
ahead: number = PREFETCH_AHEAD,
): number {
if (items.length === 0 || first < 0 || last < first) return 0;
const from = Math.max(0, first - ahead);
const to = Math.min(items.length - 1, last + ahead);
let issued = 0;
// Forward first: it is the direction a scroll is usually going, so
// it is the half that has to win the race.
for (let i = last + 1; i <= to; i++) {
const item = items[i];
if (item !== undefined && prefetchImage(urlOf(item))) issued++;
}
for (let i = from; i < first; i++) {
const item = items[i];
if (item !== undefined && prefetchImage(urlOf(item))) issued++;
}
return issued;
}
/** Forget what has been asked for. For tests; the app never needs it. */
export function resetImagePrefetch(): void {
requested.clear();
inFlight.clear();
}
-201
View File
@@ -1,201 +0,0 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler delegated or
* per-row, in any shadow root runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
@@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController {
private host: SelectionHost;
private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null;
private _mode = false;
/**
* Whether the list is in *selection mode* (plan 019, #63).
*
* A finger has no modifier keys, so the ctrl/shift semantics this
* controller was written for cannot be expressed by touch at all.
* Selection mode is the platform's answer: a long press enters it,
* and while it is on, a tap toggles a row instead of playing it.
*
* It is a flag *here* rather than a fifth concept beside the
* controller because all four surfaces that select
* (`track-list`, `queue-panel` and both playlist detail views)
* already share this class -- so "is this list selecting" has one
* answer per list, in the object that already owns the selection
* it would otherwise contradict.
*
* A mouse never sets it. Desktop selection is unchanged and stays
* modeless, which is what `handleItemClick` still implements.
*/
get selectionMode(): boolean {
return this._mode;
}
constructor(host: SelectionHost) {
this.host = host;
@@ -136,8 +159,64 @@ export class SelectionController implements ReactiveController {
return true;
}
/**
* Enter selection mode with `key` selected.
*
* The row the gesture was made on is selected, rather than the
* mode opening empty: a long press is a statement about *that*
* row, and an action bar with nothing in it is a mode the user has
* to make a second gesture to escape.
*/
enterSelectionMode(key: string, index: number): void {
this._mode = true;
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Toggle one row, and leave the mode when the last one goes.
*
* Deselecting everything is how Android's own list surfaces exit
* selection mode, and it matters more here than convention: the
* mode changes what a tap *means*, so a mode with an empty
* selection is a list where tapping does nothing and nothing on
* screen says why.
*/
toggleInMode(key: string, index: number): void {
const next = new Set(this._selectedItems);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selectedItems = next;
this.lastSelectedIndex = index;
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Leave selection mode, dropping the selection with it. */
exitSelectionMode(): void {
if (!this._mode && this._selectedItems.size === 0) return;
this._mode = false;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Clear the entire selection. */
clear(): void {
// The mode goes with it: every caller of this means "the
// selection is no longer meaningful", and a mode outliving the
// selection it was showing is the empty-mode trap above.
this._mode = false;
if (this._selectedItems.size === 0) return;
this._selectedItems = new Set();
@@ -176,6 +255,13 @@ export class SelectionController implements ReactiveController {
if (next.size === this._selectedItems.size) return;
this._selectedItems = next;
// A refetch that emptied the selection also ends the mode --
// otherwise removing the last selected track from the library
// leaves the list in a state where a tap selects and the bar
// is gone.
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
+362
View File
@@ -0,0 +1,362 @@
import { css, html, nothing } from 'lit';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { queueStore } from '@store/queue-store';
import { ICON_QUEUE } from '@utils/icon-language';
import type { SwipeEvent } from '@utils/touch-gestures';
/**
* Swipe a row right to add it to the queue (plan 019, #63).
*
* This is the *affordance* and the arithmetic, written once, because
* three lists want it: `track-list` and both playlist detail views.
* It was `track-list`'s own for one phase and is here rather than
* copied twice, on the rule the rest of this app is built on three
* copies of "how far is far enough" is three chances for them to
* disagree, which is what `utils/library-status.ts` and
* `utils/ownership.ts` each exist to have stopped happening.
*
* The host keeps three things: what a row *is*, what a swipe on it
* would queue, and what to call it afterwards. Everything else
* the threshold, the reveal, the settle, the announcement, the
* repaint is here.
*
* **The queue panel deliberately does not use it.** A right swipe means
* *add to the queue* everywhere it exists, and a queue row is already
* in the queue; the only thing it could sensibly mean there is
* *remove*, which is the same gesture with the opposite effect one
* screen away. Removing a queue row is on its own row (the ×), on its
* bottom sheet since #60, and on the selection bar #63 gave it.
*
* Five things about it are load-bearing.
*
* **Both halves of the device fix are here or next door.**
* `swipeRevealStyles` carries `touch-action: pan-y` on `[data-swipe]`,
* and `utils/touch-gestures.ts` carries the non-passive
* `preventDefault`. Chrome 113's WebView cancels the pointer stream
* ~16px into any drag whatever `touch-action` says, and with the
* `preventDefault` alone but `touch-action` at `auto` the gesture dies
* after one move. Neither works without the other and **both are
* correct in Chromium either way**, which is why the component tier
* asserts the stylesheet rather than the rendering.
*
* **The row does not move; its cells do.** A row here is
* `contain: strict` with `overflow: hidden`, so translating the row
* and counter-translating a pane inside it puts that pane at a
* negative offset inside a clipping box, where it is simply not
* painted. Sliding the children instead leaves the pane where it was
* drawn, clips the cells off the right edge, and needs no wrapper
* element in a row that is already a grid.
*
* **The travel is written to the row's own style, never rendered.**
* One render when the gesture starts, one when it crosses the
* threshold, one when it ends a virtualizer re-rendering every
* visible row per frame of one finger's travel is exactly what audit
* `perf.m1` is about.
*
* **The threshold is a fraction of the row**, with a floor. The row is
* 424x52 on the reference device, so a threshold in bare pixels is a
* fraction of a row height on one screen and a third of the width on
* the next.
*
* **It is not only a colour** (WCAG 1.4.1, the rule the playing-row
* marker exists for). The pane carries the queue icon and words, the
* words change at the threshold, and the outcome goes to a live
* region one glyph throughout, because a tick is `ICON_IN_LIBRARY`
* and means *you own this*.
*/
/** How far along the row a swipe has to reach to mean it. */
export const SWIPE_COMMIT_FRACTION = 0.3;
/** … and a floor, for a narrow list embedded in a detail page. */
export const SWIPE_COMMIT_MIN_PX = 72;
/** How long the reveal holds its confirmation before snapping back. */
const CONFIRM_MS = 550;
/** The snap itself. `swipeRevealStyles` states the same number. */
const SETTLE_MS = 180;
/** What a swipe on one row would do, as the host understands it. */
export interface SwipeTarget {
/** Which row draws the reveal. */
index: number;
/** The file paths a commit queues, in the order they are shown. */
filePaths: string[];
/** What to call a single track when saying it was added. */
label: string;
}
export interface SwipeToQueueOptions {
/**
* The row the gesture is on, or null for anything that is not a
* swipeable row a header, a gap, a track with no file.
*/
resolve(e: SwipeEvent): SwipeTarget | null;
/**
* Repaint the rows. A `<lit-virtualizer>` renders through the
* `virtualize` directive and reacts to its *own* properties, so a
* host update alone leaves the rows exactly as they were.
*/
repaint(): void;
}
export class SwipeToQueue implements ReactiveController {
private host: ReactiveControllerHost;
private opts: SwipeToQueueOptions;
/** Which row is being swiped, and therefore draws a reveal. */
private index: number | null = null;
/** Past the commit threshold: the reveal says so, in words. */
private armed = false;
/** Committed, and holding its confirmation. */
private done = false;
private row: HTMLElement | null = null;
private keys: string[] = [];
private commitPx = 0;
private settleTimer = 0;
/** What the gesture did, for anyone not watching the row. */
announcement = '';
constructor(host: ReactiveControllerHost, opts: SwipeToQueueOptions) {
this.host = host;
this.opts = opts;
host.addController(this);
}
hostConnected(): void {
// No-op; state is component-local.
}
hostDisconnected(): void {
window.clearTimeout(this.settleTimer);
this.forget();
}
/** Whether this row is the one under the finger. */
isSwiping(index: number): boolean {
return this.index === index;
}
onSwipeStart = (e: SwipeEvent): void => {
// Rightward only. Nothing is bound to a leftward swipe, and
// claiming one would take a gesture away to do nothing with it.
if (e.detail.dx <= 0) return;
const target = this.opts.resolve(e);
if (!target || target.filePaths.length === 0) return;
const row = (e.target as HTMLElement).closest(
'[data-swipe]',
) as HTMLElement | null;
if (!row) return;
e.preventDefault();
this.row = row;
this.keys = target.filePaths;
this.trackLabel = target.label;
this.commitPx = Math.max(
SWIPE_COMMIT_MIN_PX,
row.getBoundingClientRect().width * SWIPE_COMMIT_FRACTION,
);
this.armed = false;
this.done = false;
this.index = target.index;
this.host.requestUpdate();
this.opts.repaint();
this.offset(0);
};
onSwipeMove = (e: SwipeEvent): void => {
if (this.index === null) return;
const dx = Math.min(Math.max(e.detail.dx, 0), this.commitPx * 2);
const armed = dx >= this.commitPx;
if (armed !== this.armed) {
this.armed = armed;
this.host.requestUpdate();
this.opts.repaint();
}
this.offset(dx);
};
onSwipeEnd = (e: SwipeEvent): void => {
if (this.index === null) return;
if (e.detail.canceled || e.detail.dx < this.commitPx) {
this.settle(0);
return;
}
queueStore.addTracksToQueue(this.keys);
const count = this.keys.length;
// The reveal is the only thing on screen that says this
// happened -- the queue panel may well be closed -- so it holds
// its confirmation for a moment rather than vanishing the
// instant the finger lifts.
this.done = true;
this.announcement =
count === 1
? `Added ${this.label()} to the queue.`
: `Added ${count} tracks to the queue.`;
this.host.requestUpdate();
this.opts.repaint();
this.settle(CONFIRM_MS);
};
/** What is revealed behind the row, in three states. */
renderReveal(index: number) {
if (this.index !== index) return nothing;
const count = this.keys.length;
const what = count === 1 ? 'to queue' : `${count} tracks to queue`;
const words = this.done
? 'Added'
: this.armed
? 'Release to add'
: `Add ${what}`;
return html`
<div
class=${classMap({ 'swipe-reveal': true, armed: this.armed })}
aria-hidden="true"
data-testid="swipe-reveal"
>
<wa-icon name=${ICON_QUEUE}></wa-icon>
<span>${words}</span>
</div>
`;
}
/**
* What to call a single track, taken when the gesture starts.
*
* Held rather than looked up at the end, because a swipe outlives
* a refetch: the store replaces its array when a play count
* changes, which is once a song.
*/
private trackLabel = '';
private label(): string {
return this.trackLabel === '' ? 'the track' : this.trackLabel;
}
/** Write the travel to the row itself, with no render. */
private offset(dx: number): void {
this.row?.style.setProperty('--yj-swipe-dx', `${dx}px`);
}
/**
* Put the row back, after `delay`, and forget the swipe.
*
* The row element is held rather than looked up again: a
* virtualizer recycles its rows, and by the time this runs the
* element may be drawing a different track. Clearing the property
* off whatever it holds now is right either way, since `index` is
* what decides who draws the reveal.
*/
private settle(delay: number): void {
const row = this.row;
window.clearTimeout(this.settleTimer);
this.settleTimer = window.setTimeout(() => {
row?.classList.add('settling');
this.offset(0);
this.settleTimer = window.setTimeout(() => {
row?.classList.remove('settling');
row?.style.removeProperty('--yj-swipe-dx');
this.forget();
this.host.requestUpdate();
this.opts.repaint();
}, SETTLE_MS);
}, delay);
}
private forget(): void {
this.row = null;
this.index = null;
this.armed = false;
this.done = false;
}
}
/**
* The reveal, and the `touch-action` half of what makes the gesture
* reach us on the device.
*
* Keyed on `[data-swipe]` rather than on a class name, so one
* stylesheet serves three lists whose rows are called three different
* things.
*/
export const swipeRevealStyles = css`
/* Half of what makes the gesture reach us on Chrome 113's WebView:
auto lets it commit to a horizontal pan on the first move past
slop, and the pointer stream is cancelled before any threshold
can be crossed. The other half is the non-passive preventDefault
in utils/touch-gestures.ts, and neither works alone -- both were
measured three ways on the phone. Never none: that takes the
list's own vertical scrolling with it. */
[data-swipe] {
touch-action: pan-y;
}
.swipe-reveal {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--yj-swipe-dx, 0px);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.4em;
padding-left: 8px;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
font-size: var(--yj-text-xs);
background-color: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-secondary, #b3b3b3);
}
.swipe-reveal.armed {
background-color: var(--yj-success, #2f9e44);
color: var(--yj-success-fg, #fff);
}
/* The children move, not the row -- see the header. */
[data-swipe].swiping > :not(.swipe-reveal) {
transform: translateX(var(--yj-swipe-dx, 0px));
}
[data-swipe].settling > * {
transition:
transform 160ms ease-out,
width 160ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
[data-swipe].settling > * {
transition: none;
}
}
`;
+619
View File
@@ -0,0 +1,619 @@
/**
* The touch gestures, as one document listener (plan 019, #63).
*
* This replaces `utils/long-press.ts` rather than sitting beside it,
* and that is the point: two document listeners both claiming the
* 500ms hold is exactly the fault that file's own header warns about.
* What it did one capture listener, the target from
* `composedPath()[0]`, the browser's own gesture winning, the trailing
* click swallowed is kept whole. What changes is what the gesture
* *means*.
*
* **A gesture is announced, not acted on.** Two composed, cancelable
* events are dispatched on the element the finger actually landed on:
*
* `yj-tap` a short press that did not drift
* `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
* `preventDefault()`. Nothing else changes. That shape is what lets
* this reassign the hold without touching a single one of the fourteen
* context menus downstream of it: **an unclaimed `yj-long-press` still
* becomes a synthetic `contextmenu`**, so a card grid, an Explore
* result or a playlist row behaves exactly as it did, and only the
* lists that opt in get selection mode.
*
* The same rule keeps taps honest. An unclaimed `yj-tap` does nothing
* at all and the browser's click follows normally, so every button,
* link and checkbox in the app is untouched by this file. Only a
* claimed tap has its click swallowed otherwise playing a track
* would also select it.
*
* Five things are load-bearing.
*
* **The predicate is the pointer, not the platform** (plan 019,
* decision 1). `pointerType === 'touch'`, per event so an Android
* tablet over 600px, a touchscreen laptop with a mouse also plugged
* in, and a narrow desktop window are all right for free, and there is
* no second declaration of what a phone does. Keyed on a viewport
* width, the first of those three gets desktop semantics on a
* touchscreen, which is the inversion #63 exists to fix, on the
* platform it exists for.
*
* **There is no double-tap**, and it is not an omission see plan
* 019, decision 2. Measured on the reference device, the play command
* to `TrackChanged` is ~100ms; a double-tap discriminator has to hold
* every tap for the app's own `DOUBLE_CLICK_GRACE_MS` of 250 before it
* can act, which is 3.5x the primary interaction in the app to reach a
* menu that long-press already reaches.
*
* **The target comes from `composedPath()[0]`**, not
* `elementFromPoint`, which stops at the outermost shadow host: every
* list in this app delegates inside one, so an event dispatched on the
* host reaches a delegated listener and no per-row one.
*
* **A browser that fires its own `contextmenu` is a trigger, not a
* competitor**, and that is a change from `long-press.ts` rather than
* an inherited rule. It used to stand down when a trusted
* `contextmenu` arrived, because both paths ended in the same place: a
* context menu. They no longer do ours may end in selection mode
* so standing down means the gesture silently does the *old* thing.
*
* Measured on the reference device, which is the only tier that can
* see this: Chrome 113's WebView fires its own `contextmenu` on a long
* press, so a hold on a track row opened the context menu and
* `yj-long-press` was never announced at all. Every test in the
* component tier passed, because dispatched pointer events do not make
* a browser synthesise one.
*
* So a trusted `contextmenu` arriving mid-press *becomes* the long
* press: `yj-long-press` is announced from it, and only if a component
* claims it is the native event suppressed. Unclaimed, it propagates
* untouched and opens the menu it always did which is the same
* "browser wins" outcome, now reached by asking rather than assuming.
*
* Ours and the browser's are still told apart by identity rather than
* `isTrusted` a `WeakSet` of the events this module made so the
* suppressor cannot eat the event it exists to deliver, and a test can
* stand in for a browser that fires one.
*
* **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
* 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". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
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. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
x: 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 SwipeEvent = CustomEvent<SwipeDetail>;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
'yj-swipe-start': SwipeEvent;
'yj-swipe-move': SwipeEvent;
'yj-swipe-end': SwipeEvent;
}
}
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`. */
const ours = new WeakSet<Event>();
/**
* Install the gestures. Idempotent; returns the uninstaller (which the
* tests use the app installs once and never removes it).
*/
export function installTouchGestures(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** The press is still a candidate for a tap: it has neither
* drifted nor become a long press. */
let tapCandidate = false;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** A gesture was claimed, and the click ending it is not a click on
* anything. */
let swallowClick = false;
/**
* 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;
// --- 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 => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
tapCandidate = false;
};
/**
* Announce a gesture on the element the finger landed on.
* Returns whether a component claimed it.
*/
const announce = (name: 'yj-tap' | 'yj-long-press', el: EventTarget): boolean => {
const event: GestureEvent = new CustomEvent<GestureDetail>(name, {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
detail: { x: originX, y: originY },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
const fireContextMenu = (el: EventTarget): void => {
justFired = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onLongPress = (): void => {
timer = null;
tapCandidate = false;
const el = target;
target = null;
if (nativeSeen || !el) return;
// The gesture happened either way, so the click that ends it is
// never a click on anything -- whether a list claimed it for
// selection mode or a card grid let it fall through to a menu.
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
// the whole reason the fourteen context menus need no change.
if (!announce('yj-long-press', el)) fireContextMenu(el);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
tapCandidate = true;
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 => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
// A drifted press is neither gesture -- it is a scroll, and the
// virtualizer's, not ours.
if (drifted) cancel();
};
const onPointerUp = (): void => {
const el = target;
const wasTap = tapCandidate && timer !== null;
// Clears the long-press timer, so a tap cannot also become one.
cancel();
if (!wasTap || !el) return;
// Only a *claimed* tap swallows its click. An unclaimed one has
// to fall through untouched, or every button in the app stops
// working.
if (announce('yj-tap', el)) swallowClick = true;
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser recognised the same hold this was timing.
// Use its event as the trigger rather than racing it --
// and rather than standing down, which is what the old
// rule did and which now silently means "do the thing this
// gesture used to do".
const el = e.composedPath()[0] ?? e.target;
nativeSeen = true;
cancel();
if (!el) return;
swallowClick = true;
// Claimed: the component wants selection mode, so the
// browser's menu must not also open. Unclaimed: let it
// through exactly as before.
if (announce('yj-long-press', el)) {
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
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('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', onPointerUp, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, 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
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', abort, listening);
uninstall = () => {
abort();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', onPointerUp, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, 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;
};
return uninstall;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,190 @@
/**
* The grids ask for the art below the fold before the card exists
* (#65).
*
* Reported as "scrolling through albums, the art pops in". The cards
* already draw the smallest adequate tier and are already
* `loading="lazy"`, so what was left is *when*: `<lit-virtualizer>`
* renders about 1000px past the viewport and the `<img>` and
* therefore the request does not exist until it does. On the
* reference device that is about two screens.
*
* These assert the mechanism, since no tier here can photograph a
* pop-in: that the rows past the rendered range are requested, that
* the request is for the same tier the card will draw, and that the
* window has an end an unbounded prefetch of a 5 000-album library
* is the failure this trades against.
*
* What is *not* asserted here is that a rendered card was never
* prefetched. It often was, honestly: the grid lays out more than once
* on mount, so a row warmed by the first pass is drawn by the second,
* which is the whole point. The rule that a single pass skips its own
* rendered range is `image-prefetch.test.ts`'s, where one call can be
* looked at on its own.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/cover-grid/cover-grid';
import '@components/artists-view/artists-view';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadowAll } from '@test/support/render';
import {
PREFETCH_AHEAD,
imagePrefetched,
resetImagePrefetch,
} from '@utils/image-prefetch';
/** Enough albums that the virtualizer's own window is nowhere near the end. */
const ALBUMS = Array.from({ length: 400 }, (_, i) => {
const n = String(i + 1).padStart(4, '0');
return {
ID: i + 1,
Name: `Album ${n}`,
ArtistName: 'Aurora Fields',
Year: 2020,
CoverArtPath: `/covers/${n}.jpg`,
CoverArtSmall: `/covers/${n}_sm.jpg`,
CoverArtMedium: `/covers/${n}_md.jpg`,
CoverArtLarge: `/covers/${n}_lg.jpg`,
};
});
const ARTISTS = Array.from({ length: 400 }, (_, i) => {
const n = String(i + 1).padStart(4, '0');
return {
ID: i + 1,
Name: `Artist ${n}`,
AlbumCount: 2,
TrackCount: 9,
ImageSmall: `/artists/${n}_sm.jpg`,
ImageMedium: `/artists/${n}_md.jpg`,
ImageLarge: `/artists/${n}_lg.jpg`,
};
});
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
function sized(el: HTMLElement): void {
el.style.display = 'block';
el.style.height = '600px';
el.style.width = '900px';
}
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 200));
}
/** The `src` of every card the grid actually rendered. */
function renderedSources(el: LitElement, selector: string): string[] {
return shadowAll(el, selector)
.map((img) => (img as HTMLImageElement).getAttribute('src') ?? '')
.filter(Boolean);
}
/**
* The last index the virtualizer has rendered, read off the cards
* rather than counted: the rendered range is what the prefetch window
* is measured from, and a count assumes it starts at 0 and has no
* gaps.
*/
function lastRenderedIndex(el: LitElement, selector: string): number {
const indices = shadowAll(el, selector).map((card) =>
Number(card.getAttribute('data-index')),
);
return Math.max(...indices);
}
/**
* The tier the cards chose, read off a rendered card rather than
* recomputed the point of the assertion is that the prefetch and the
* card agree, so deriving both from the same ladder here would prove
* nothing.
*/
function tierSuffix(src: string): string {
const m = /_(sm|md|lg)\.jpg$/.exec(src);
return m ? `_${m[1]}` : '';
}
beforeEach(() => {
resetHarness();
resetImagePrefetch();
localStorage.clear();
stub('library.Library.GetAlbums', ALBUMS);
stub('library.Library.GetArtists', ARTISTS);
stub('library.Library.GetTracks', []);
stub('library.Library.GetGenres', []);
emit(Events.LibraryScanComplete);
});
describe('the albums grid warms the covers below the fold', () => {
it('asks for the covers past the rendered range, in the tier the card draws', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const rendered = renderedSources(el, 'img.cover-image');
expect(rendered.length).toBeGreaterThan(0);
const tier = tierSuffix(rendered[0]!);
const url = (index: number) =>
`/covers/${String(index + 1).padStart(4, '0')}${tier}.jpg`;
// The grid starts at the top and never scrolls here, so the whole
// window lies past the last card drawn.
const last = lastRenderedIndex(el, '.album-card');
expect(imagePrefetched(url(last + 1))).toBe(true);
expect(imagePrefetched(url(last + PREFETCH_AHEAD))).toBe(true);
});
it('stops at the end of the window rather than warming the library', async () => {
const el = await fixture<LitElement>('cover-grid');
sized(el);
await settle(el);
const rendered = renderedSources(el, 'img.cover-image');
const tier = tierSuffix(rendered[0]!);
const url = (index: number) =>
`/covers/${String(index + 1).padStart(4, '0')}${tier}.jpg`;
// Not "exactly `last + PREFETCH_AHEAD`": the grid lays out more
// than once on mount and each pass warms a window from wherever
// the rendered range was then, so the reachable set is a few
// windows wide. The property that matters is that it is a window
// at all rather than the library.
expect(imagePrefetched(url(399))).toBe(false);
expect(window.__yjCacheStats?.()['imagePrefetch']?.entries ?? 0)
.toBeLessThan(ALBUMS.length / 2);
});
});
describe('the artists grid warms its avatars the same way', () => {
it('asks for the avatars past the rendered range', async () => {
const el = await fixture<LitElement>('artists-view');
sized(el);
await settle(el);
const rendered = renderedSources(el, 'img.avatar-image');
expect(rendered.length).toBeGreaterThan(0);
const tier = tierSuffix(rendered[0]!);
const last = lastRenderedIndex(el, '.artist-card');
const url = (index: number) =>
`/artists/${String(index + 1).padStart(4, '0')}${tier}.jpg`;
expect(imagePrefetched(url(last + 1))).toBe(true);
expect(imagePrefetched(url(399))).toBe(false);
});
});
+115
View File
@@ -193,4 +193,119 @@ describe('bottom-nav', () => {
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
.toBe(true);
});
it('draws "More" as a sheet rising from the bottom', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement>(el, 'wa-drawer');
// #71. A side drawer is a desktop shape: it opened away from the
// thumb that asked for it and drew a 200px column of a 424px
// screen. `placement` is the whole of the change to *where* it
// comes from, and `without-header` is what makes it the same sheet
// `menu-surface` draws rather than a second pattern with a title
// bar and a close button.
expect(drawer?.getAttribute('placement')).toBe('bottom');
expect(drawer?.hasAttribute('without-header')).toBe(true);
// Named all the same: `nameDialog`'s documented aria-label path,
// since without-header renders no heading to point at.
expect(drawer?.getAttribute('label')).toBe('All views');
});
it('leaves exactly one scroll container, and it is the sheet body', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
await update(el, {});
// The report is "only part of the screen scrolls under my finger",
// and the cause is three boxes that each scroll: the dialog, its
// body, and the sidebar's own overflow-y host. Which one a drag
// moves depends on where the finger landed.
const dialog = drawer.shadowRoot?.querySelector('[part~="dialog"]');
const body = drawer.shadowRoot?.querySelector('[part~="body"]');
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
if (!dialog || !body || !sidebar) throw new Error('no sheet');
expect(getComputedStyle(dialog).overflowY).toBe('hidden');
expect(getComputedStyle(body).overflowY).toBe('auto');
expect(getComputedStyle(sidebar).overflowY).toBe('visible');
// And the one that does scroll keeps it to itself, or reaching the
// end of the destinations scrolls the page behind the sheet.
expect(getComputedStyle(body).overscrollBehaviorY).toBe('contain');
});
it('gives the sheet the whole width, which the sidebar does not take', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
if (!sidebar) throw new Error('no sidebar');
// `app-sidebar` writes an *inline* width and caps itself at 400px,
// which beats any rule this host could write — so "the host owns
// the box" has to be part of what `expanded` means, or the sheet
// draws the old 200px column inside a full-width surface.
expect(sidebar.style.width).toBe('100%');
expect(getComputedStyle(sidebar).maxWidth).toBe('none');
});
it('sizes the sheet rows for a thumb, below the phone breakpoint', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const sidebar = shadow<HTMLElement>(el, 'app-sidebar');
const sheets = sidebar?.shadowRoot?.adoptedStyleSheets ?? [];
const phoneRules: string[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (!(rule instanceof CSSMediaRule)) continue;
if (!/max-width:\s*599px/.test(rule.conditionText)) continue;
for (const inner of Array.from(rule.cssRules)) {
phoneRules.push(inner.cssText);
}
}
}
// Asserted against the parsed stylesheet, like
// `hover-affordance.test.ts` and for the same reason: this tier's
// iframe is not 599px wide, so the rule cannot be *rendered* here —
// but the regression worth catching is someone moving it out of the
// query, which nothing on a desktop draws differently.
expect(phoneRules.length).toBeGreaterThan(0);
expect(phoneRules.some((r) => /min-height:\s*48px/.test(r))).toBe(true);
});
it('takes the resize handle out of the sheet', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
const handle = shadow<HTMLElement>(el, 'app-sidebar')
?.shadowRoot?.querySelector('.resize-handle');
if (!handle) throw new Error('no resize handle');
// A col-resize strip on the right edge of a touch surface: the
// compatibility mouse events a tap synthesises reach its
// `mousedown`, so it can start a resize nobody asked for.
expect(getComputedStyle(handle).display).toBe('none');
});
});
+5
View File
@@ -137,6 +137,11 @@ describe('<app-sidebar>', () => {
});
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');
await visual(el, 'app-sidebar');
@@ -0,0 +1,230 @@
/**
* The controls #186's second table found, outside Settings.
*
* Six one-off controls across four surfaces, and the reason they are a
* test rather than six stylesheet edits is `back-button`. The issue
* names it in `artist-details` at **32x32**; it is the same
* declaration, byte-identical, in *six* components because a device
* sweep walks the views somebody thought to open, and five of them
* were not opened.
*
* So the assertion is over the whole set rather than over the one that
* was measured. That is `icon-language.test.ts`'s shape and it is here
* for the same reason: checking one call site checks one call site.
*
* | control | before | where |
* |---|---|---|
* | `.folders-menu-trigger` | **32x18** | autotag |
* | `.section-toggle` | 187x**15** | autotag |
* | `.back-button` | 32x32 | six detail views |
* | Requests / Downloads tabs | 85x**34**, 96x**34** | downloads |
* | `.search-mode-tab` | 89x**26**, 79x**26** | explore |
* | explore search input | 325x**18** in a 36px box | explore |
*
* `page-action-check-now` (113x29) is in that table and is **not**
* here: it is a `PageAction`, so #195 raised it with the rest of the
* page header's actions, and `touch-targets.test.ts` already covers
* it. Re-asserting it here would be a second statement of one rule.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/artist-details/artist-details';
import '@components/autotag-view/autotag-view';
import '@components/downloads-view/downloads-view';
import '@components/explore-album-details/explore-album-details';
import '@components/explore-artist-details/explore-artist-details';
import '@components/explore-view/explore-view';
import '@components/genre-details/genre-details';
import '@components/playlist-details/playlist-details';
import '@components/smart-playlist-details/smart-playlist-details';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Every component that draws a back button.
*
* The list is here rather than derived because deriving it means
* reading the source, and this tier renders instead but it is
* checked against the source by `the back button is one declaration`
* below, so a seventh view cannot join quietly.
*/
const BACK_BUTTON_VIEWS = [
'artist-details',
'genre-details',
'playlist-details',
'smart-playlist-details',
'explore-artist-details',
'explore-album-details',
] as const;
function boxOf(el: Element | null | undefined): { w: number; h: number } {
if (!el) return { w: 0, h: 0 };
const box = el.getBoundingClientRect();
return { w: Math.round(box.width), h: Math.round(box.height) };
}
describe('the way out of a detail view', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetTracks',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'playlist.Service.GetAllPlaylists',
'playlist.Service.GetAllPlaylistsWithTracks',
]) {
stub(path, []);
}
});
it.each(BACK_BUTTON_VIEWS)('is 44px in <%s>', async (tag) => {
// #55 settled this one component over, when the queue panel's
// close button was 25x21 and, at phone width, the only pointer
// route off a full-screen surface: "the way out is 44px". A detail
// view has the platform's back gesture as well, so it is less
// severe -- and it is the same control wearing the same mistake.
const el = await fixture(tag);
await flush();
const back = shadow(el, '.back-button');
expect(back, `${tag} draws a back button`).toBeTruthy();
expect(boxOf(back)).toEqual({ w: FLOOR, h: FLOOR });
});
it('is one declaration, so a seventh view cannot miss it', async () => {
// The regression this exists for is not a size changing -- it is
// somebody adding a detail view and writing `.back-button` out
// again at 32px, which is exactly how there came to be six copies.
// A sweep of the running app would not catch it either, because a
// sweep visits the views you think to open.
const sources = import.meta.glob('../../src/components/**/*.ts', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
expect(Object.keys(sources).length, 'the glob read something').toBeGreaterThan(0);
const redeclared = Object.entries(sources)
.filter(([, src]) => /^\s*\.back-button\s*(?::[a-z-]+\s*)?\{/m.test(src))
.map(([path]) => path);
expect(redeclared).toEqual([]);
});
});
describe('autotag', () => {
it('raises the two smallest controls the sweep found', async () => {
// 187x15 and 32x18. The section toggle was the smallest control
// measured anywhere in the app until the column arrows were
// counted, and autotag is off by default (#25), which is
// presumably why nobody had met either.
const el = await fixture('autotag-view');
await flush();
for (const selector of ['.section-toggle', '.folders-menu-trigger']) {
const control = shadowAll<HTMLElement>(el, selector).find(
(c) => c.getBoundingClientRect().height > 0,
);
if (!control) continue;
expect(boxOf(control).h, `${selector} height`).toBeGreaterThanOrEqual(FLOOR);
}
// The stylesheet is the assertion for whichever of the two this
// fixture does not render -- both are behind state a bare mount
// does not reach, and a test that silently checked nothing is the
// trap icon-language.test.ts's first assertion exists for.
const sheet = (el.constructor as typeof HTMLElement & { styles?: unknown })
.styles;
expect(String(sheet)).toContain('min-block-size: 44px');
});
});
describe('the Downloads tabs', () => {
beforeEach(() => {
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
stub('download.Service.ListProviders', []);
});
it('are the only route to their panels, and are 44px', async () => {
const el = await fixture('downloads-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '[role="tab"]');
expect(tabs).toHaveLength(2);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('keeps the active underline against the label', async () => {
// The height is padding rather than a min-size, because the mark
// for the selected tab is the bottom border -- a min-size would
// centre the label and leave the underline 10px below it.
const el = await fixture('downloads-view');
await flush();
const tab = shadowAll<HTMLElement>(el, '[role="tab"]')[0]!;
const style = getComputedStyle(tab);
expect(parseFloat(style.paddingBlockStart)).toBeGreaterThan(8);
expect(style.paddingBlockStart).toBe(style.paddingBlockEnd);
});
});
describe("Explore's own search row", () => {
beforeEach(() => {
stub('explore.Service.GetShelves', { State: 'ready', Shelves: [] });
stub('explore.Service.GetIndexStatus', {});
});
it('raises the mode tabs', async () => {
const el = await fixture('explore-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '.search-mode-tab');
expect(tabs.length).toBeGreaterThan(0);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('makes the whole search box the input, not the middle 18px of it', async () => {
// Two faults, not one: the row was 36px and the input inside it
// was **18**, so half the box was not a target at all -- a tap
// near the top or bottom edge landed on the container and did
// nothing. The container is 44 and the input stretches to fill it.
const el = await fixture('explore-view');
await flush();
const box = shadow(el, '.search-container');
const input = shadow(el, '.search-container input');
expect(box, 'the search row renders').toBeTruthy();
expect(input, 'it holds an input').toBeTruthy();
expect(boxOf(box).h).toBeGreaterThanOrEqual(FLOOR);
expect(boxOf(input).h).toBeGreaterThanOrEqual(FLOOR);
});
});
@@ -1,5 +1,6 @@
/**
* A hover affordance is gated on the device having hover.
* A hover affordance is gated on the device having hover in whichever
* direction keeps the action reachable.
*
* The home page's cover cards reveal a play button on :hover. A touch
* long-press synthesises a hover state in the WebView, so on a phone
@@ -7,6 +8,15 @@
* utils/long-press.ts is measuring for a context menu a control
* appearing because the user was reaching for a different one.
*
* #137 is the same sweep with the opposite answer for two of its three
* cases. Where the revealed control is the *only* route to its action,
* hiding it removes the action, so it is always visible where there is
* no hover: `track-details`'s cover-art overlay and remove, and
* `shortcut-capture`'s reset. The queue's per-row remove is the third,
* and is the redundant kind since #60 the row's context menu is a
* bottom sheet carrying "Remove from Queue" so it takes #68's
* treatment here.
*
* This is asserted against the *parsed stylesheet* rather than by
* emulating a touch device, and that is a limitation worth stating
* rather than hiding. CDP's Emulation.setEmulatedMedia does not reach
@@ -24,6 +34,9 @@
import { describe, expect, it } from 'vitest';
import '@components/home-view/home-view';
import '@components/queue-panel/queue-panel';
import '@components/track-details/track-details';
import '@components/config-page/shortcut-capture';
import { fixture } from '@test/support/render';
/** Every rule in the element's own adopted stylesheets, flattened. */
@@ -83,3 +96,91 @@ describe('the home card play button', () => {
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
});
});
describe("the queue row's remove button", () => {
it('is absent where the device has no hover, the menu carrying the action', async () => {
const el = await fixture('queue-panel', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
// visibility:hidden alone would leave an invisible button holding
// its hit area on a phone, which is the trap #68's commit names.
const unconditional = rules.filter(
(r) => r.condition === null && r.text.startsWith('.remove-button'),
);
expect(unconditional.length).toBeGreaterThan(0);
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
const reveals = rules.filter(
(r) =>
r.text.includes('.remove-button') && /visibility:\s*visible/.test(r.text),
);
expect(reveals.length).toBeGreaterThan(0);
for (const rule of reveals) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
});
/**
* The two affordances that are the only route to their action.
*
* Asserted as "there is a rule showing it, and its condition is a
* *negated* hover query" the same stylesheet reading as above, for
* the same reason: this tier's iframe cannot be emulated as a touch
* device, and the regression worth catching is someone folding the rule
* away as redundant on the desktop it does nothing on.
*/
describe('an affordance with no other route', () => {
const cases: Array<[string, string, string[]]> = [
['track-details', 'track-details', ['.cover-art-overlay', '.cover-art-remove']],
['shortcut-capture', 'shortcut-capture', ['.reset-btn']],
];
for (const [name, tag, selectors] of cases) {
it(`${name} shows it where the device has no hover`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
for (const selector of selectors) {
const shown = rules.filter(
(r) =>
r.condition !== null &&
r.text.includes(selector) &&
/opacity:\s*1/.test(r.text),
);
const touch = shown.filter((r) => /not[\s\S]*hover:\s*hover/.test(r.condition!));
expect(touch.length).toBeGreaterThan(0);
}
});
}
// The one half this tier can measure rather than read: the query is
// negated, so on the hover-capable browser running these tests the
// control must still be revealed by hover and by nothing else. A rule
// written without the `not` would show it here, permanently, on every
// desktop.
it('leaves the desktop reveal alone, where the device does have hover', async () => {
expect(matchMedia('(hover: hover)').matches).toBe(true);
const el = await fixture('shortcut-capture', {
action: 'player.next',
label: 'Next Track',
currentKey: 'X',
defaultKey: 'N',
});
const btn = el.shadowRoot?.querySelector('.reset-btn');
expect(btn).not.toBeNull();
expect(getComputedStyle(btn!).opacity).toBe('0');
});
});
-212
View File
@@ -1,212 +0,0 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
@@ -206,6 +206,56 @@ describe('menu-surface', () => {
expect(dismissed, 'no menu-dismiss reached the document').toBe(1);
});
/**
* The scroll affordance (#207), and this is the mechanism again
* rather than the symptom.
*
* The sheet's body has scrolled since #60 and said nothing about
* it: measured at 424x439, eight items ended at y=470 with the
* fold at 439, and where the cut lands on a row boundary the sheet
* ends in a clean edge that reads as the end of the list.
*
* What makes the fade *conditional* absent on a menu that fits,
* present the moment one does not, gone again at the end of the
* list is `background-attachment`, not a scroll listener: a cover
* of the sheet's own colour is painted at the end of the content
* and attached `local`, over a shadow pinned to the box and
* attached `scroll`. So the pair of attachments *is* the feature,
* and it is what this asserts. The rendered result was measured in
* the harness (dark ramp 52,58,64 flat before; 52,57,63 at the last
* label and 22,24,27 at the bottom edge with more below; flat again
* at the end of the list) and is on the PR.
*/
it('paints the fade only while there is more below', async () => {
const el = await surfaceWithPanel();
const wrapper = el.shadowRoot?.querySelector('wa-dialog');
await (wrapper as HTMLElement & { updateComplete: Promise<unknown> })
.updateComplete;
const body = wrapper?.shadowRoot?.querySelector('[part~="body"]');
expect(body, 'no body part to scroll').not.toBeNull();
const style = getComputedStyle(body as Element);
expect(style.overflowY, 'the body is what gives, not the cap').toBe(
'auto',
);
// The cover scrolls with the content; the shadow does not. Either
// one alone is a fade that is always there or never there.
expect(
style.backgroundAttachment,
'the cover must be local and the shadow must not',
).toBe('local, scroll');
// Both sit at the bottom, or the cover hides nothing.
expect(style.backgroundPosition).toBe('50% 100%, 50% 100%');
expect(style.backgroundSize).toBe('100% 32px, 100% 32px');
});
/**
* A dialog with no accessible name is what `utils/name-dialog.ts`
* exists for; here the name is already written on the panel, so no
@@ -366,6 +366,16 @@ describe('<now-playing>', () => {
const el = await fixture('now-playing');
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 el.updateComplete;
@@ -0,0 +1,275 @@
/**
* A name is not a link on a phone, and the menu is where it went (#67).
*
* `utils/explore-link.ts` makes every track, album and artist name
* navigable, with click handling that is explicitly a desktop
* compromise the navigation is held for one double-click interval so
* double-clicking the row can still play it. On touch that is a delay
* on an ambiguous target, and since #63 the row's own tap claims the
* click anyway, so the link was unreachable as well as fiddly.
*
* So below the phone breakpoint a name renders as plain text and the
* row's context menu carries "Go to Artist" / "Go to Album" instead.
* The two halves are asserted together on purpose: a suppressed link
* with no menu item behind it is not a smaller affordance, it is a
* destination that cannot be reached, which is what plan 018 promises
* against.
*
* The breakpoint is stubbed rather than emulated for the reason
* `now-playing-phone.test.ts` states: this tier's viewport is fixed at
* 1280x800 by the runner, and `matchMedia` is the seam.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { html, render } from 'lit';
import type { LitElement } from 'lit';
import '@components/playlist-details/playlist-details';
import {
albumLink,
artistLink,
creditLink,
trackLink,
} from '@utils/explore-link';
import { goToMenuItems } from '@utils/go-to-menu';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
/** Answer the phone breakpoint, and hand back the undo. */
function atPhone(phone: boolean): () => void {
const real = window.matchMedia.bind(window);
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
matches: phone,
media: q,
addEventListener() {},
removeEventListener() {},
}
: real(q)) as typeof window.matchMedia;
return () => {
window.matchMedia = real as typeof window.matchMedia;
};
}
/** Render a template into a detached container and hand it back. */
function draw(template: unknown): HTMLElement {
const host = document.createElement('div');
document.body.append(host);
render(html`${template}`, host);
return host;
}
describe('an inline name below the phone breakpoint', () => {
let restore: () => void = () => {};
afterEach(() => {
restore();
document.querySelectorAll('body > div').forEach((el) => el.remove());
});
it('is a link on a desktop', () => {
restore = atPhone(false);
const host = draw(artistLink('Cocteau Twins', 'artist-mbid'));
expect(host.querySelector('a.explore-link')).not.toBeNull();
expect(host.textContent?.trim()).toBe('Cocteau Twins');
});
it('is plain text on a phone, for all four shapes', () => {
restore = atPhone(true);
const host = draw(html`
${artistLink('Cocteau Twins', 'artist-mbid')}
${albumLink('Heaven or Las Vegas', 'rg-mbid')}
${trackLink('Iceblink Luck', 'Heaven or Las Vegas', 'rg-mbid', 'rec-mbid')}
${creditLink(
[
{
creditedName: 'Skrillex',
artistMbid: 'a1',
joinPhrase: ' feat. ',
},
{ creditedName: 'Swae Lee', artistMbid: 'a2', joinPhrase: '' },
],
'Skrillex & Swae Lee',
'a1',
)}
`);
expect(host.querySelectorAll('a.explore-link')).toHaveLength(0);
// The words survive, join phrases included — a decomposed credit is
// still assembled from its parts, so the text does not change with
// the affordance.
expect(host.textContent).toContain('Cocteau Twins');
expect(host.textContent).toContain('Heaven or Las Vegas');
expect(host.textContent).toContain('Iceblink Luck');
expect(host.textContent).toContain('Skrillex feat. Swae Lee');
});
it('stays a link where the caller has no menu to carry it', () => {
restore = atPhone(true);
const host = draw(
albumLink('Heaven or Las Vegas', 'rg-mbid', undefined, 'Cocteau Twins', {
keepOnPhone: true,
}),
);
expect(host.querySelector('a.explore-link')).not.toBeNull();
});
});
describe('the "Go to" menu items', () => {
let restore: () => void = () => {};
afterEach(() => {
restore();
document.querySelectorAll('body > div').forEach((el) => el.remove());
});
it('are absent on a desktop, where the name beside them is a link', () => {
restore = atPhone(false);
const host = draw(
goToMenuItems({ artistName: 'Cocteau Twins', albumName: 'Treasure' }),
);
expect(host.querySelectorAll('wa-dropdown-item')).toHaveLength(0);
});
it('offer only what the target knows', () => {
restore = atPhone(true);
const both = draw(
goToMenuItems({ artistName: 'Cocteau Twins', albumName: 'Treasure' }),
);
const artistOnly = draw(goToMenuItems({ artistName: 'Cocteau Twins' }));
const neither = draw(goToMenuItems({}));
expect(both.querySelectorAll('wa-dropdown-item')).toHaveLength(2);
expect(artistOnly.querySelectorAll('wa-dropdown-item')).toHaveLength(1);
expect(neither.querySelectorAll('wa-dropdown-item')).toHaveLength(0);
});
});
// =====================================================================
// The menu that carries the destination
// =====================================================================
function playlistTracks(n: number) {
return Array.from({ length: n }, (_, i) => ({
ID: i + 1,
FilePath: `/music/track-${i}.mp3`,
Title: `Track ${i}`,
Artist: 'Cocteau Twins',
ArtistMBID: 'artist-mbid',
Album: 'Heaven or Las Vegas',
ReleaseGroupMBID: 'rg-mbid',
Duration: 180000,
Phantom: false,
}));
}
describe('a playlist rows context menu on a phone', () => {
let el: LitElement;
let restore: () => void = () => {};
beforeEach(async () => {
resetHarness();
restore = atPhone(true);
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
stub('playlist.Service.GetAllPlaylists', []);
el = await fixture<LitElement>('playlist-details', {
playlistId: 1,
playlistName: 'A playlist',
});
el.style.display = 'block';
el.style.height = '600px';
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
});
afterEach(() => {
restore();
});
/** Right-click a row and hand back the menu's items. */
async function openMenu(index: number): Promise<HTMLElement[]> {
const row = shadowAll(el, '.track-item').find(
(r) => r.getAttribute('data-index') === String(index),
);
row!.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, composed: true }),
);
await el.updateComplete;
return shadowAll<HTMLElement>(el, 'wa-dropdown-item');
}
it('carries the artist and the album the row stopped linking to', async () => {
const labels = (await openMenu(3)).map((i) => i.textContent?.trim());
expect(labels).toContain('Go to Artist');
expect(labels).toContain('Go to Album');
});
it('navigates where the name would have', async () => {
const seen: CustomEvent[] = [];
const listen = (e: Event) => seen.push(e as CustomEvent);
document.addEventListener('navigate', listen);
try {
const items = await openMenu(3);
items
.find((i) => i.textContent?.trim() === 'Go to Artist')!
.click();
await flush();
} finally {
document.removeEventListener('navigate', listen);
}
expect(seen.map((e) => e.detail)).toEqual([
{
view: 'explore-artist-details',
artistMBID: 'artist-mbid',
artistName: 'Cocteau Twins',
},
]);
});
it('is absent while several rows are selected', async () => {
// "Go to the album" of five different albums means nothing, which
// is the rule the Play item already follows: one row is a
// position, several are an explicit choice of those tracks.
const rows = shadowAll(el, '.track-item');
const click = (i: number, modifiers: MouseEventInit) =>
rows
.find((r) => r.getAttribute('data-index') === String(i))!
.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
composed: true,
...modifiers,
}),
);
click(1, {});
click(4, { ctrlKey: true });
await el.updateComplete;
const labels = (await openMenu(4)).map((i) => i.textContent?.trim());
expect(labels).not.toContain('Go to Artist');
});
});
@@ -161,6 +161,15 @@ describe('double-clicking a row in the track list', () => {
localStorage.removeItem('track-list-column-widths');
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.height = '600px';
await flush();
@@ -172,6 +181,10 @@ describe('double-clicking a row in the track list', () => {
const rows = shadowAll(el, '.track-row');
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!);
await flush();
@@ -0,0 +1,191 @@
/**
* What a tap looks like now that the web view's own highlight is gone
* (#54).
*
* `index.css` sets `-webkit-tap-highlight-color: transparent` on
* `html`, which the property being inherited reaches every shadow
* root in the app. That takes away the grey box a phone drew over the
* bounding rect of whatever was tapped, and with it the only touch
* feedback the rows, the tab bar, the sidebar's destinations and the
* shared menu items had. So the press states below are not decoration:
* without them this change trades wrong feedback for none.
*
* **Asserted against the parsed stylesheet**, on `hover-affordance`'s
* precedent and with the same limitation stated out loud: CDP's
* `Emulation.setEmulatedMedia` does not reach this tier's iframe, so
* there is no way here to render a component as a phone would, and
* `:active` cannot be forced from a test either. What the browser will
* answer is the shape it built from the `css` literal which rule sits
* inside which media query, and what the press selector actually is.
*
* Two regressions are worth catching that way, and both are silent on a
* desktop:
*
* - someone hoisting a hover tint back out of its query as a tidy-up,
* which on a phone is a highlight that arrives because a finger
* touched the row and stays after it has gone;
* - someone simplifying the press selector to a bare `:active`, which
* is one class short of `.selected` / `.active` and so does nothing
* on the row a phone is most likely to press the one it has just
* selected.
*
* The pixels are the Android tier's, and the tap highlight itself is
* `e2e/specs/native-touch-feel.spec.ts`, since only the real app loads
* `index.css` at all.
*/
import { describe, expect, it } from 'vitest';
import '@components/track-list/track-list';
import '@components/queue-panel/queue-panel';
import '@components/playlist-details/playlist-details';
import '@components/smart-playlist-details/smart-playlist-details';
import '@components/bottom-nav/bottom-nav';
import '@components/sidebar/app-sidebar';
import { fixture } from '@test/support/render';
/** Every rule in the element's own adopted stylesheets, flattened. */
function rulesOf(host: Element): { text: string; condition: string | null }[] {
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
const out: { text: string; condition: string | null }[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSMediaRule) {
for (const inner of Array.from(rule.cssRules)) {
out.push({ text: inner.cssText, condition: rule.conditionText });
}
continue;
}
out.push({ text: rule.cssText, condition: null });
}
}
return out;
}
/** The four lists, their row selector, and the tag that draws them. */
const LISTS: Array<[string, string]> = [
['track-list', '.track-row'],
['queue-panel', '.track-item'],
['playlist-details', '.track-item'],
['smart-playlist-details', '.track-item'],
];
describe('a row says it is being pressed', () => {
for (const [tag, row] of LISTS) {
it(`${tag} draws a press state that survives its state classes`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
// Worth nothing if it read no rules at all — the first assertion
// icon-language.test.ts makes, for the same reason.
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter(
(r) => r.text.includes(`${row}:active`) && r.text.includes('background-color'),
);
expect(press.length).toBeGreaterThan(0);
for (const rule of press) {
// A press is not a hover: it is the one thing a touch device
// can say, so it must not sit behind a pointer query.
expect(rule.condition).toBeNull();
expect(rule.text).toContain('--yj-press-overlay');
}
// The load-bearing half: the selector carries a state class, or
// it loses to `.selected` / `.selected.active` and the press is
// invisible on a selected or playing row.
expect(press.some((r) => r.text.includes(`${row}.selected:active`))).toBe(true);
});
it(`${tag} keeps its hover tint for devices that hover`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const hover = rules.filter(
(r) =>
r.text.includes(`${row}:hover`) &&
r.text.includes('--yj-hover-overlay'),
);
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
}
});
describe('the two navigations say they are being pressed', () => {
it('the phone tab bar, which had no state of its own at all', async () => {
const el = await fixture('bottom-nav', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter((r) => r.text.startsWith('button:active'));
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
});
it("the sidebar, which is also the phone's More sheet", async () => {
const el = await fixture('app-sidebar', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
const press = rules.filter((r) => r.text.startsWith('li button:active'));
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
// Its hover tint is a destination looking picked, if it is left to
// a synthesised hover inside the More sheet.
const hover = rules.filter((r) => r.text.startsWith('li button:hover'));
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
}
});
});
describe('the shared context menu', () => {
// One stylesheet, fourteen menus — the same reason the sheet's row
// height lives there rather than in each host.
it('presses its items, in the one place every menu includes', async () => {
const el = await fixture('queue-panel', {});
const rules = rulesOf(el);
const press = rules.filter((r) =>
r.text.startsWith('.context-menu-panel wa-dropdown-item:active'),
);
expect(press.length).toBe(1);
expect(press[0]!.condition).toBeNull();
expect(press[0]!.text).toContain('--yj-press-overlay');
const hover = rules.filter((r) =>
r.text.startsWith('.context-menu-panel wa-dropdown-item:hover'),
);
expect(hover.length).toBeGreaterThan(0);
for (const rule of hover) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
});
@@ -29,8 +29,61 @@ import { shadow } from '@test/support/render';
const wrappers: HTMLElement[] = [];
let restoreMedia: (() => void) | null = null;
/**
* Answer the shell's phone query with `phone` until restored.
*
* Stubbed rather than emulated, for the reason `search-dialog.test.ts`
* gives: the runner's viewport is fixed at 1280x800, and the panel
* reads `matchMedia` in `connectedCallback` precisely so a test can
* answer it first.
*/
/**
* Answering the phone query is not enough on its own: what decides
* whether the scrim exists is a `change` listener, and a stub whose
* `addEventListener` is a no-op leaves that listener untested the
* whole suite stays green with it deleted. So the stub records the
* listeners and hands back a way to fire them.
*/
function stubPhone(phone: boolean): (next: boolean) => void {
const real = window.matchMedia.bind(window);
const listeners = new Set<(e: MediaQueryListEvent) => void>();
let matches = phone;
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
get matches() {
return matches;
},
media: q,
addEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.add(fn);
},
removeEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.delete(fn);
},
}
: real(q)) as typeof window.matchMedia;
restoreMedia = () => {
window.matchMedia = real;
};
return (next: boolean) => {
matches = next;
for (const fn of listeners) {
fn({ matches: next } as MediaQueryListEvent);
}
};
}
afterEach(() => {
for (const w of wrappers.splice(0)) w.remove();
restoreMedia?.();
restoreMedia = null;
});
/**
@@ -157,6 +210,78 @@ describe('the queue panel decides whether it can be a column', () => {
}
});
/**
* #171 the scrim is a dismissal target, so it exists only where it
* has pixels to be tapped.
*
* Below 600px `.panel-content` is `width: 100%`, so the scrim is
* entirely underneath an opaque panel: measured at 424x439, host,
* panel and scrim all 424x318. Drawing it there is a `cursor:
* pointer` click target nobody can reach, and the queue is a screen
* at that width anyway (#55) back and the close button are its ways
* out. Existence rather than `display: none`, because a hidden scrim
* is still an element carrying the handler.
*/
it('draws no scrim at phone width, where it would have no reachable pixels', async () => {
stubPhone(true);
const el = await panelIn(424);
expect(el.overlay).toBe(true);
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
// The way out a thumb can hit is still there.
expect(
shadow(el, '[data-testid="queue-close"]')?.getAttribute('aria-label'),
).toBe('Close queue');
});
/**
* The 600899 band is where the panel is a 320px column of a wider
* content area, so the scrim has uncovered pixels and #24's
* tap-outside-to-close is real. Same width as the overlay tests
* above, with the phone query explicitly answered `false`, so this
* fails if the scrim is ever dropped for every overlay.
*/
it('keeps the scrim above phone width, where it can be tapped', async () => {
stubPhone(false);
const el = await panelIn(700);
expect(el.overlay).toBe(true);
shadow<HTMLElement>(el, '.scrim')?.click();
await el.updateComplete;
expect(el.open).toBe(false);
});
/**
* The scrim's existence comes from `matchMedia` rather than a
* stylesheet, which only holds up if the query is *listened* to a
* panel opened on a desktop and carried across the breakpoint (a
* resized window, an unfolded phone) has to lose its scrim without
* being reopened. Nothing else in this file fires `change`, so
* deleting the listener leaves the whole suite green.
*/
it('drops the scrim when the viewport crosses the breakpoint', async () => {
const setPhone = stubPhone(false);
const el = await panelIn(700);
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
setPhone(true);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
setPhone(false);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
});
/**
* Escape belongs to the overlay, not to the queue. An inline panel is
* beside the content rather than over it, so there is nothing to
@@ -99,6 +99,25 @@ describe('<search-trigger>', () => {
}
});
it('meets the touch floor it was shipped four pixels under', async () => {
stubPhone(true);
// #57 created this as the phone's replacement for the header search
// box, so it exists *only* where there is a thumb -- and it shipped
// at 40x40 under a comment calling that "the smallest a touch
// target should be", which was the app's own 44px floor (#56)
// restated short rather than a second opinion about it. #186.
const el = await fixture('search-trigger');
const button = shadow<HTMLButtonElement>(el, '[data-testid="search-trigger"]');
expect(button).not.toBeNull();
const box = button!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(44);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(44);
});
it('names what the button will search', async () => {
stubPhone(true);
@@ -0,0 +1,211 @@
/**
* The seek bar's painted track and the thing you can hit are allowed to
* differ, and a slider is the clearest case where they should.
*
* On `now-playing-view` the screen that exists so a phone has
* somewhere to seek from the slider measured 261x6 on the reference
* device (#187). Six pixels is the whole of the drag target on the
* app's primary seeking affordance, against a 44px floor the app set
* for itself in #56 and holds to in the queue panel.
*
* Two separate faults, and the first is why the second was not obvious.
*
* **The phone rule had never applied.** `seek-bar`'s stylesheet asked
* for a 12px track below 599px and then set 6px in a plain `wa-slider`
* rule *written after it*. A media query adds no specificity, so the
* plain rule won at every width which is `index.css`'s documented
* rule ("the phone section is last on purpose") reproduced inside a
* component's own stylesheet. The source said 12 and the device said 6.
*
* **And 12px would still be under the floor**, so the target is built
* around the track rather than by thickening it: padding on the part
* that carries the gesture, with margins cancelling it so the row does
* not grow.
*
* This is asserted against the *parsed stylesheet*, on
* `hover-affordance.test.ts`'s precedent and with the same limitation
* stated rather than hidden: no tier here renders at a phone width with
* a real `wa-slider` laid out, so what can be checked is the shape the
* browser built from the css`` literal. The pixel measurements that
* chose these numbers were taken on the device and are recorded on
* #187 and in the stylesheet's own comment a number measured on a
* phone is not a number CI can assert.
*
* Which is the regression worth catching anyway. Both failures are
* invisible on a desktop: hoisting the block back above the plain rule
* renders identically at every width CI runs at, and it is exactly what
* a tidy-up does.
*/
import { describe, expect, it } from 'vitest';
import '@components/audio-player/seekbar/seek-bar';
import { fixture } from '@test/support/render';
/** The app's touch floor, from #56. */
const TOUCH_FLOOR = 44;
/** The width below which the phone's rules apply. */
const PHONE_QUERY = /max-width:\s*599px/;
type Rule = { text: string; condition: string | null };
/**
* Every rule in the element's own adopted stylesheets, flattened **in
* order**, which is the whole point here: the fault being guarded is a
* rule sitting in the wrong place, not a rule being absent.
*/
function rulesOf(host: Element): Rule[] {
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
const out: Rule[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSMediaRule) {
for (const inner of Array.from(rule.cssRules)) {
out.push({ text: inner.cssText, condition: rule.conditionText });
}
continue;
}
out.push({ text: rule.cssText, condition: null });
}
}
return out;
}
/**
* The two px numbers of a `*-block` declaration, as [start, end].
*
* A symmetric pair is **serialised back as one value** `padding-block:
* 16px 16px` reads as `padding-block: 16px` — so a naive pair-reader
* fails on the shorthand rather than on the thing it is checking, and
* says the wrong thing about why. That is not hypothetical: it is what
* the symmetric-padding reversion did while this test was being
* proved.
*/
function blockPair(text: string, property: string): [number, number] | null {
const declaration = new RegExp(`${property}:\\s*([^;]+)`).exec(text)?.[1];
if (declaration === undefined) {
return null;
}
const values = [...declaration.matchAll(/(-?[\d.]+)px/g)].map((m) =>
Number(m[1]),
);
const [start, end] = values;
if (start === undefined) {
return null;
}
return [start, end ?? start];
}
describe("the seek bar's phone rules", () => {
it('are last, so they are not silently overridden', async () => {
const el = await fixture('seek-bar', {});
const rules = rulesOf(el);
// A sweep that read nothing passes vacuously — the same first
// assertion icon-language.test.ts makes, for the same reason.
expect(rules.length).toBeGreaterThan(0);
const declaresTrackSize = (r: Rule) => /--track-size:/.test(r.text);
const lastUnconditional = rules.findLastIndex(
(r) => r.condition === null && declaresTrackSize(r),
);
const phoneOverride = rules.findLastIndex(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& declaresTrackSize(r),
);
expect(lastUnconditional).toBeGreaterThanOrEqual(0);
expect(phoneOverride).toBeGreaterThanOrEqual(0);
// A media query adds no specificity. Written first, it loses.
expect(phoneOverride).toBeGreaterThan(lastUnconditional);
});
it('give the slider a pointer target of at least the touch floor', async () => {
const el = await fixture('seek-bar', {});
const rules = rulesOf(el);
const track = rules.find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& /--track-size:/.test(r.text),
);
const target = rules.find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(track).toBeDefined();
expect(target).toBeDefined();
const trackSize = Number(
/--track-size:\s*(-?[\d.]+)px/.exec(track!.text)?.[1],
);
const padding = blockPair(target!.text, 'padding-block');
expect(padding).not.toBeNull();
// The padding is on ::part(slider) rather than on the host because
// that inner div is what carries the gesture: it has the listener
// and the touch-action, and it is exactly the host's size, so
// padding the host grows a box that does not take the press.
const hitArea = trackSize + padding![0] + padding![1];
expect(hitArea).toBeGreaterThanOrEqual(TOUCH_FLOOR);
});
it('do not grow the row they sit in', async () => {
const el = await fixture('seek-bar', {});
const target = rulesOf(el).find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(target).toBeDefined();
const padding = blockPair(target!.text, 'padding-block');
const margin = blockPair(target!.text, 'margin-block');
expect(padding).not.toBeNull();
expect(margin).not.toBeNull();
// now-playing-view's vertical budget is fixed and #51 measured
// every pixel of it: letting the row grow by the difference cost
// the album art 25px of 143 when it was tried on the device.
expect(margin![0]).toBe(-padding![0]);
expect(margin![1]).toBe(-padding![1]);
});
it('take the space above, because what is below is the transport', async () => {
const el = await fixture('seek-bar', {});
const target = rulesOf(el).find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(target).toBeDefined();
const pair = blockPair(target!.text, 'padding-block');
expect(pair).not.toBeNull();
const [above, below] = pair!;
// Measured at 424x439: the seek row is 19px and the play button's
// top edge is 8px below it, while `.art` above is a non-interactive
// div. A symmetric target would reach into the play button — the
// most important control on the screen — so the growth is upward.
expect(above).toBeGreaterThan(below);
});
});
@@ -0,0 +1,425 @@
/**
* Every control in Settings is at least 44px (#186, second pass).
*
* The header pass covered the five controls a user meets on every
* screen. Settings is the other half and is much the larger one: swept
* on the reference device (TLP301, 424x439) with all eleven
* `config-section`s expanded, **120 controls** were under the floor,
* not the 93 the issue's first table implies, and `config-field` the
* row shape the issue names is eight of them. The bulk is behind the
* disclosures:
*
* | control | size | count |
* |---|---|---|
* | `.column-arrow-btn` | **16x14** | 36 |
* | `.column-toggle` | 16x16 | 29 |
* | `shortcut-capture` button | 80x**25** | 26 |
* | download format checkbox | 16x16 | 8 |
* | `config-field` select | 335x**30** | 7 |
* | `wa-input` / `wa-button` | 204x**20**, 185x**21** | 6 |
* | `library-filter` select | 120x**32** | 1 |
* | `.overflow-btn` | 31x31 | 1 |
*
* **This tier can measure it, unlike #187's seek bar**, for the reason
* the header pass gives: the controls are real elements and the rules
* are min-sizes, so a real Chromium rendering a real component gives
* the actual answer at any width. And unlike the header there is no
* overflow fit on this page, so nothing here needs the negative-margin
* treatment height is free and the two square controls can simply be
* square.
*
* **What the sweep cannot see is written down here as a test rather
* than as a comment**, because it is the trap this whole issue keeps
* setting. Two controls are invisible to a walk of `button, select,
* input`: `config-field`'s toggle, whose `<input>` is
* `opacity: 0; width: 0; height: 0` so the thing a finger hits is the
* `<label>` around it (34x19, smaller than anything in either of the
* issue's tables), and `shortcut-capture`'s reset button, which renders
* only for a shortcut somebody has already rebound. Both are asserted
* by name below.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/config-page/config-field';
import '@components/config-page/config-page';
import '@components/config-page/download-clients';
import '@components/config-page/shortcut-capture';
import '@components/library-filter/library-filter';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Everything a finger can hit, through every shadow root under `root`.
*
* It descends rather than querying one root because Settings is a tree
* of components `config-page` renders `config-section`s holding
* `config-field`s and `shortcut-capture`s and the defect was
* distributed across all of them. This is the device sweep, run here.
*/
function controlsUnder(root: Document | ShadowRoot | Element): { name: string; el: HTMLElement }[] {
const SELECTOR = 'button, select, input, [role="button"], [role="tab"], [role="switch"]';
const found: { name: string; el: HTMLElement }[] = [];
const seen = new Set<Element>();
const walk = (node: ParentNode, depth: number): void => {
if (depth > 20) return;
for (const el of Array.from(node.querySelectorAll('*'))) {
if (el.matches(SELECTOR) && !seen.has(el)) {
seen.add(el);
const box = el.getBoundingClientRect();
const style = getComputedStyle(el);
const rendered =
(box.width > 0 || box.height > 0) &&
style.visibility !== 'hidden' &&
style.display !== 'none';
if (rendered) {
const host = (el.getRootNode() as ShadowRoot).host;
found.push({
name: `${host ? host.tagName.toLowerCase() : 'root'} ${
(typeof el.className === 'string' && el.className) || el.tagName.toLowerCase()
}`,
el: el as HTMLElement,
});
}
}
if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
}
};
walk(root as ParentNode, 0);
return found;
}
/**
* What a finger actually hits for `el`.
*
* For everything in this app that is one element, that is the element.
* A native checkbox is the exception and is why this function exists:
* it cannot grow its hit area without growing its paint, and a 44px
* checkbox is not what anyone wants so a checkbox that has a label
* is targeted *through* the label, which is the fix the column lists
* and the download formats both use.
*
* The fallback is the checkbox itself, deliberately: a checkbox with
* no label is a 16px target and this must still say so.
*/
function hitTarget(el: HTMLElement): HTMLElement {
const input = el as HTMLInputElement;
if (input.type !== 'checkbox' && input.type !== 'radio') return el;
const wrapping = el.closest('label');
const root = el.getRootNode() as ShadowRoot | Document;
const associated = input.id
? root.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(input.id)}"]`)
: null;
return wrapping ?? associated ?? el;
}
/** The ones that miss the floor, reported with the numbers. */
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const box = hitTarget(el).getBoundingClientRect();
return { name, w: Math.round(box.width), h: Math.round(box.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
/**
* Open every disclosure under `host`, so the sweep can see the page.
*
* A collapsed `config-section` renders its body with `hidden`, so its
* controls measure 0x0 which is exactly why the issue's first table
* lists seven Settings controls and the real count is 120.
*/
async function expandEverySection(host: HTMLElement & { updateComplete: Promise<unknown> }) {
const sections = shadowAll<HTMLElement>(host, 'config-section');
expect(sections.length, 'the page renders disclosures to open').toBeGreaterThan(0);
for (const section of sections) {
section.shadowRoot
?.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')
?.click();
}
await flush();
await host.updateComplete;
for (const section of sections) {
await (section as HTMLElement & { updateComplete?: Promise<unknown> }).updateComplete;
}
return sections;
}
/** A control's own box, named so a failure says which and how small. */
function boxOf(el: Element | null | undefined): string {
if (!el) return 'missing';
const box = el.getBoundingClientRect();
return `${Math.round(box.width)}x${Math.round(box.height)}`;
}
function meetsFloor(el: Element | null | undefined): boolean {
if (!el) return false;
const box = el.getBoundingClientRect();
return Math.round(box.width) >= FLOOR && Math.round(box.height) >= FLOOR;
}
describe('a config field is the shape every Settings row uses', () => {
it.each(['text', 'number', 'select', 'directory', 'color'] as const)(
'a %s field meets the floor',
async (type) => {
const el = await fixture('config-field', {
schema: {
key: 'k',
label: 'Music folder',
type,
options: [{ value: 'dark', label: 'Dark' }],
},
value: type === 'color' ? '#ffd43b' : '',
});
const controls = controlsUnder(el.shadowRoot!);
// A sweep that found nothing passes vacuously.
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
},
);
it('grows the toggle, which no sweep of inputs can see', async () => {
// The `<input>` is opacity: 0; width: 0; height: 0, so the walk
// above skips it as a zero-sized node -- and the thing a finger
// hits is the styling <label> around it, which measured 34x19 on
// the device. It is absent from #186's tables for exactly that
// reason, and it is smaller than everything in them.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch');
expect(boxOf(target)).toBe('44x44');
});
it('keeps the toggle painted at its old size, in its old place', async () => {
// A 44px pill is not what a switch should look like. The box is
// 44px and the paint is not: the slider is a child centred in it,
// and negative inline margins hand the extra width back so the
// pill stays flush with the inputs in the rows above.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch') as HTMLElement;
const slider = shadow(el, '.toggle-slider');
expect(slider!.getBoundingClientRect().height).toBeLessThan(FLOOR);
const style = getComputedStyle(target);
const handedBack =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(handedBack).toBeLessThan(0);
});
});
describe('the shortcut editor', () => {
it('meets the floor', async () => {
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'Space',
defaultKey: 'Space',
});
expect(meetsFloor(shadow(el, 'button'))).toBe(true);
});
it('grows the reset button, which only a rebound shortcut renders', async () => {
// Not in either of #186's tables, and it cannot be: a sweep of a
// freshly-installed app never sees it. It appears the moment
// anybody uses the feature.
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'K',
defaultKey: 'Space',
});
const reset = shadow(el, '.reset-btn');
expect(reset, 'a rebound shortcut renders a reset button').toBeTruthy();
expect(meetsFloor(reset)).toBe(true);
});
});
describe('the library filter', () => {
it('meets the floor in both of its placements', async () => {
// One component, two mount points since #57 -- the desktop top bar
// and Settings -> Libraries -- so it reaches the floor once.
const el = await fixture('library-filter');
const select = shadow(el, 'select');
expect(select, 'the filter renders a select').toBeTruthy();
expect(Math.round(select!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
});
describe('download clients', () => {
beforeEach(() => {
stub('download.Service.ListProviders', []);
stub('download.Service.ProviderKinds', []);
stub('config.Config.GetDownloadPreferences', {});
});
it('gives Web Awesome form controls the floor through the library API', async () => {
// wa-input's control is inside somebody else's shadow root, so the
// height comes from --wa-form-control-height rather than from a
// rule of ours reaching in. A custom property inherits through a
// shadow boundary, which is what makes a :host declaration reach
// it -- and what makes it measurable here.
const el = await fixture('download-clients');
await flush();
await expandEverySection(el);
expect(getComputedStyle(el).getPropertyValue('--wa-form-control-height').trim()).toBe(
'44px',
);
// And the outcome, not only the mechanism. A sweep of `input`
// reports a wa-input at 204x**42** even when this is right,
// because the inner input sits *inside* the control's own 1px
// border -- Web Awesome sizes it
// `calc(--wa-form-control-height - border-width * 2)`. What a
// finger hits is `part=base`, measured at 238x44 on the device.
//
// This reaches into another library's shadow root, which
// `name-dialog.ts` only permits where the failure is bounded. It
// is bounded the other way here: this is a test, so a renamed
// part fails loudly rather than silently passing, which is the
// direction that costs nobody a device session.
const input = shadowAll<HTMLElement>(el, 'wa-input').find(
(w) => w.getBoundingClientRect().height > 0,
);
expect(input, 'the add form renders a wa-input').toBeTruthy();
const base = input!.shadowRoot?.querySelector('[part~="base"]');
expect(base, 'wa-input still calls its control box "base"').toBeTruthy();
expect(Math.round(base!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
it('makes each allowed-format checkbox label a target', async () => {
const el = await fixture('download-clients');
await flush();
await el.updateComplete;
// Every section starts collapsed, and a collapsed body is `hidden`
// — so its controls measure 0x0 and a sweep of an unexpanded page
// reports them all as fine. That is how the issue's first table
// came to list seven Settings controls when there are 120.
await expandEverySection(el);
const options = shadowAll<HTMLElement>(el, '.format-option');
expect(options.length).toBeGreaterThan(0);
const short = options
.map((o) => ({ label: o.textContent?.trim(), h: Math.round(o.getBoundingClientRect().height) }))
.filter((o) => o.h < FLOOR);
expect(short).toEqual([]);
});
});
describe('the whole Settings page', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetAllLibrariesWithTrackCounts',
'jobs.Service.GetJobs',
'download.Service.ListProviders',
'download.Service.ProviderKinds',
]) {
stub(path, []);
}
stub('config.Config.GetShortcuts', {});
stub('config.Config.GetDownloadPreferences', {});
stub('config.Config.GetThemeAccentColor', '#ffd43b');
stub('config.Config.GetThemeBackgroundShade', 'dark');
});
it('has no control under the floor with every section expanded', async () => {
// The device sweep, run here: eleven collapsed sections is what
// made the first table look like seven controls. The density is
// behind the disclosures.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const controls = controlsUnder(el.shadowRoot!);
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
});
it('makes a column row a target by naming it, not by growing the checkbox', async () => {
// A native checkbox cannot grow its hit area without growing its
// paint. The label is the target instead -- which is also the
// argument config-field already makes for its own labels, and it
// is behaviour rather than annotation: the column's name is now a
// click target for its checkbox.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const labels = shadowAll<HTMLLabelElement>(el, 'label.column-label');
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
const target = label.htmlFor
? el.shadowRoot!.getElementById(label.htmlFor)
: null;
expect(
(target as HTMLInputElement | null)?.type,
`${label.textContent?.trim()} names its checkbox`,
).toBe('checkbox');
expect(
Math.round(label.getBoundingClientRect().height),
`${label.textContent?.trim()} is a target`,
).toBeGreaterThanOrEqual(FLOOR);
}
});
});
@@ -0,0 +1,681 @@
/**
* The touch gestures (plan 019, #63; long-press from plan 016 B2).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installTouchGestures,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/touch-gestures';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('an unclaimed long press is still a context menu', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
/**
* What plan 019 adds on top, and the one property that protects
* everything downstream: a long press nobody claims is unchanged.
*/
describe('a gesture is announced before it is acted on', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('does not synthesise a menu when the long press is claimed', async () => {
// This is the whole reason #63 could reassign the hold without
// touching one of the fourteen context menus: the lists that want
// selection mode claim it, and nothing else changes.
const menus = recordMenus(inner);
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(HELD);
expect(presses).toHaveLength(1);
expect(menus).toHaveLength(0);
});
it('announces a tap on the element touched, at the touch point', async () => {
const taps: { target: EventTarget | null; x: number; y: number }[] = [];
inner.addEventListener('yj-tap', (e) => {
taps.push({ target: e.target, x: e.detail.x, y: e.detail.y });
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
expect(taps).toHaveLength(1);
// The row, not its shadow host -- the difference between a
// delegated handler firing and a per-row one never firing.
expect(taps[0]?.target).toBe(inner);
expect([taps[0]?.x, taps[0]?.y]).toEqual([40, 60]);
});
it('lets an unclaimed tap through as an ordinary click', async () => {
// Every button, link and checkbox in the app depends on this. Only
// a *claimed* tap has its click swallowed.
let clicks = 0;
inner.addEventListener('yj-tap', () => {
/* seen, not claimed */
});
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('swallows the click behind a claimed tap', async () => {
// Or playing a track would also select it, and the row would end
// up in both states at once.
let clicks = 0;
inner.addEventListener('yj-tap', (e) => e.preventDefault());
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
});
it('does not announce a tap for a press that became a long press', async () => {
// A hold is one gesture, not a hold and then a tap on release.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('does not announce a tap for a press that drifted', async () => {
// A drifted press is a scroll, and the virtualizer's -- not a tap
// that happened to move. This is the one that would make a list
// unscrollable if it were wrong.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40,
clientY: 60 + MOVE_TOLERANCE_PX + 20,
});
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('ignores a mouse entirely, for both gestures', async () => {
// plan 019 decision 1: the predicate is the pointer, per event. A
// mouse on a touchscreen keeps click-selects / double-click-plays
// on the very same row, which no viewport width can express.
const seen: unknown[] = [];
inner.addEventListener('yj-tap', (e) => seen.push(e));
inner.addEventListener('yj-long-press', (e) => seen.push(e));
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(BRIEF);
press(inner, 'pointerup', { pointerType: 'mouse' });
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
});
/**
* The browser's own long press is a trigger, not a competitor.
*
* This is a device-only defect made checkable here. `long-press.ts`
* stood down when a trusted `contextmenu` arrived mid-press, which was
* right while both paths ended in a context menu. Once a hold can mean
* *selection mode*, standing down means the gesture silently does the
* old thing and Chrome 113's Android WebView does fire its own, so
* on the reference device `yj-long-press` was never announced at all
* while every test in this tier passed.
*
* A test cannot dispatch a *trusted* event, which is exactly why the
* module tells its own apart by identity rather than by `isTrusted`:
* an untrusted one dispatched from here takes the same path the
* browser's does.
*/
describe("the browser's own long press", () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
/** Stand in for the browser recognising the hold itself. */
function browserContextMenu(el: EventTarget): MouseEvent {
const e = new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
});
el.dispatchEvent(e);
return e;
}
it('announces the gesture rather than standing down', async () => {
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
expect(presses, 'the hold reached the component').toHaveLength(1);
});
it('suppresses its menu when a component claims the gesture', async () => {
const menus = recordMenus(inner);
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
// The component wants selection mode, so the menu must not also
// open -- otherwise the device shows both at once.
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 () => {
// The old behaviour, reached by asking instead of assuming. This
// is what leaves the card grids, Explore and the playlist rows
// untouched by #63.
const menus = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
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);
});
});

Some files were not shown because too many files have changed in this diff Show More