Compare commits

...
Author SHA1 Message Date
logan ec4961ae50 test(e2e): name the fixture tracks that really have no album
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m47s
CI / e2e (pull_request) Successful in 10m17s
`queueSixAndOpen` filters the queue down to tracks that have an album,
because `explore-link` renders a name it cannot route as plain text and
one test clicks that name. The filter is right and unchanged; the
comment explaining it named the wrong two files.

Since #104 read a WAV's `id3 ` chunk, the two tracks under `Field
Recordings/Test Tones` are tagged, scanned and ordinary. Asked of a
seeded app rather than of the comment, exactly two tracks in the
fixture library have no album: `unsorted/no-tags-at-all.mp3` and
`unsorted/title-only.mp3`.

The clause saying which change made the old names wrong is there so the
next reader does not restore them.

Closes #217
2026-08-26 07:42:11 -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
logan deea6ad06d test(player): pin the desktop timer gate, drop a leaked queue
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m14s
Two gaps a review found. The this.phone gate on the interpolation interval is what CLAUDE.md says earns the matchMedia call, and every test passed without it — so it is asserted on the timer count now, since a desktop render is empty either way and cannot tell the two apart. Watched failing with the gate removed.

The e2e spec left LONG_TRACK playing in a workers: 1 suite against one long-lived app, immediately before four other phone-* specs. Nine specs clear the queue in afterEach for that reason and phone-transport.spec.ts records the flake it caused.
2026-08-21 10:45:15 -04:00
logan fba608fdbd docs(player): attribute the phone seek bar's removal correctly
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Canceled after 0s
The paragraph said #59 took the seek bar off the phone's transport.
It was plan 016 B2 — audio-player.ts says so in the comment above the
rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed
shuffle, repeat and the queue button. Wrong provenance in the file
whose whole value is being right about which change did what.

Also stop tracking .pi/journal.md. It is a scheduled run's scratch log,
and this repo's memory is CLAUDE.md and .planning/ — a session log
arriving inside a feature PR is a new convention landing sideways.
2026-08-21 14:38:12 +00:00
logan f59490b113 feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a
mini player is expected to say without being opened -- how far through
the song it is -- had nowhere left to be said.

It is the shell's element and its own 2px grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing the line means reaching into the other's box. The fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId`/`seq` guards and an interval that only
interpolates *between* reports -- never its own clock, which is the
rule that exists because a local counter drifted 30 s away from the
backend across four keyboard seeks.

It is `aria-hidden` and takes no pointer events at any depth: Now
Playing's seek bar is what announces the position, and a 2px strip on
the top edge of the tab bar is exactly where a thumb aiming at a tab
lands. It renders nothing above 600px, from `matchMedia` rather than a
media query, because a stylesheet cannot stop a 1 Hz interval running
for the life of every desktop session about a line nobody can see.

Its phone rule is at the foot of index.css beside `job-band`'s, not in
the phone block above: a media query adds no specificity, so a
`display: block` written before the `display: none` that takes it out
of the desktop grid loses to it and the line never appears at all.

Closes #58
2026-08-21 14:38:12 +00:00
logan 6cca57f229 Merge pull request 'fix(explore): scroll the album page as one on a phone' (#179) from fix/66-album-page-scrolls-as-one into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 9m23s
2026-08-21 14:36:20 +00:00
logan ea3edde697 fix(explore): scroll the album page as one on a phone
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m8s
`explore-album-details` was a fixed header over a scrolling tracklist,
which is the desktop arrangement. At the reference device's 424x439 the
header owned 253 of the panel's 318px and the list scrolled inside the
64 that were left, and the header's flex row squeezed `.album-info` to
112px beside a 200px cover -- so the title drew as one ellipsised glyph
and two of the album's three primary actions were clipped by the
component's own `overflow: hidden`: "Shuffle album" ended at x=443 in a
424px box, reachable by no gesture.

Below 600px the host is the scroller and `.content` stops being one, so
the header scrolls away and the page moves together; the header stacks
art over info, so the info column has the row's whole width. The
tracklist is plain DOM rather than a virtualizer, so nothing inside
wants a scroll window of its own.

Another `min-width: 0` was not the fix and the issue's own measurement
says so: `.album-info` carries one and was shrinking as asked. Nor
could `layout-overflow.spec.ts` see any of this -- `body.scrollWidth`
equalled the viewport throughout, because the overflow was inside a
component -- so the new spec measures each header control against the
host's own box, which is `top-bar-fit.spec.ts`'s shape for the same
reason.

The phone block is last in the stylesheet on `index.css`'s rule: a
media query adds no specificity, so above the rules it overrides every
declaration in it would be silently dead.

Closes #66
2026-08-21 04:40:35 -04:00
logan 14e3ab574c Merge pull request #176: context menus are a bottom sheet on a phone
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 9m9s
2026-08-21 07:25:55 +00:00
logan 4b2eec5703 Merge remote-tracking branch 'origin/main' into 60-context-menu-action-sheet
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m51s
2026-08-21 02:39:29 -04:00
logan 3871d37fdb Merge pull request 'chore(agent): add the scheduled backlog-issue prompt' (#177) from pi-agent-backlog-automation into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m54s
Reviewed-on: #177
2026-08-21 06:29:30 +00:00
logan 09b005557c chore(agent): add the scheduled backlog-issue prompt
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 9m2s
A scheduled pi session reads this file and works one open issue end to
end: pick, claim, branch, implement, verify on the tier the change
demands, open a PR, stop. It declines rather than improvises where it
cannot verify itself — a busy :34115 means another worktree is running
the app, and a green e2e run against someone else's build is worse than
no run at all.
2026-08-21 02:28:55 -04:00
logan ef5574d18b docs(shell): record the clip, and the four things only a device showed
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m57s
CLAUDE.md gains the surface beside the keyboard model it shares, and
NOTES.md the measurements: the 83px clip with its screenshot, the probe
that established a top-layer dialog escapes paint containment from
inside a view, the UA stylesheet's 354px, the focus steal a longer
retry cannot beat, and the submenu this change pushed off-screen before
it pulled it back.

The last of those is also a note about scope: the issue was claimed
saying the submenu would be measured and filed, and the measurement
said fix it.
2026-08-21 02:24:04 -04:00
logan 31dafb0ce0 test(shell): assert the surface, and sweep for a menu that skipped it
No tier here can reproduce the defect: this runner's Chromium and CI's
WebKit both have the Popover API, so the popup is top-layered and looks
perfectly correct, and a spec asserting "the menu is not clipped" would
pass on the broken build.  So these 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, for the same reason.

The sweep is the more valuable half.  A thirteenth menu written as a
bare <wa-popup> would work in every tier here and be clipped on the
device, so this reads every source file and fails on one outside a
three-file allowlist, each entry carrying why.  It found two call sites
the by-hand conversion had missed.

Four of the six behavioural tests fail on the build before this change;
the two asserting the desktop popup cannot, because that behaviour was
already there.

Closes #60
2026-08-21 02:24:02 -04:00
logan 9e7e7ce5a1 feat(shell): put every menu in the app through the one surface
Fourteen call sites, one tag name each and nothing else -- which is what
menu-surface's shape buys: the host's panel is slotted into whichever
presentation is up, so no item model, no keyboard model and no styling
moved.  The 48px rows come from contextMenuStyles, the one stylesheet
every one of these hosts already includes, because the panel is the
host's own light DOM and only the host's stylesheet can reach it.

Two of the fourteen were found by the source sweep rather than by the
conversion: queue-panel's add-to-playlist popup, which is a real menu.
now-playing's cover preview is allowlisted instead -- it is a hover
affordance in the bottom bar, so a touch device never opens it and
nothing clips it.

The playlist submenu had to come too, and that is the one place this
change made something worse before it made it better.  It is a
placement="right-start" flyout anchored to its row, and making the menu
full-width moved that anchor to x=0 -- so the flip put the picker at
x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at
all.  Before the change the row started at x~245 and the same flip
landed on screen.  It is a sheet now and stacks over the first, which
is also why menu-shown does not re-assert focus while it is open.

The three hosts that do not use ContextMenuController -- page-header's
overflow menu, playlist-view's hand-rolled menu, queue-panel's picker
-- bind menu-dismiss themselves, or Escape would close the sheet and
leave their own open flag set.

page-header is included deliberately: the clipping does not bite there,
since it opens downward from the top of a full-height view, but on a
phone every action of an overflowing page lives in that menu at
wa-dropdown-item defaults.  One surface, so there is no second answer
to what a menu looks like.
2026-08-21 02:23:48 -04:00
logan 9aaa8beb99 feat(shell): draw a context menu where it fits, not where it is anchored
On the reference device every context menu in the app is clipped, and
the two halves of that are structural rather than incidental.  Chrome
113 has no Popover API, so wa-popup takes its own documented fallback
and positions with strategy: "fixed"; .main-panel carries
contain: layout style paint, and paint containment clips fixed
descendants.  Measured at 424x439 before any of this: the main panel
spans 0-318, the open menu spanned 191-401, and three of its seven
items were cut off with no way to reach them.  Rows were 29px against
a 44px floor.

menu-surface is one element with two presentations -- a wa-popup above
600px, a wa-dialog bottom sheet below it -- so the host keeps rendering
the panel it always rendered and ContextMenuController keeps driving
.active and .anchor as though it were talking to a popup.  showModal()
is Chrome 37 and uses the real top layer, so the sheet is immune by
construction rather than by styling.

Four things needed measuring on the hardware rather than reading.

"A dialog escapes containment" was the premise and was untested here:
every other dialog in this app is mounted in index.html, outside
.main-panel.  A probe dialog appended to track-list's shadow root
paints to y=439, over the mini player and the tab bar.

A native dialog's UA stylesheet centres it and caps its width, which
drew a 354px panel in the middle of a 424px screen -- so four
declarations in this component are pure undoing.

wa-dialog focuses [autofocus] or itself on the frame after
showModal(), and it cannot see our first menu item to prefer it: the
panel is slotted, so its own querySelector stops at the <slot>.  A
longer retry budget does not fix that, because the first attempt
succeeds and is then overwritten -- hence menu-shown and
MenuKeyboard.refocus().  The budget became time-based anyway, since
what is being waited for is another component's animation.

And a dismissal has to travel back: wa-dialog closes itself on Escape,
which would leave the controller believing the menu is open.  The
failure mode there is not a stuck sheet but the *next* long-press
doing nothing, which reads as the gesture breaking.
2026-08-21 02:23:33 -04:00
logan 2e29e67664 Merge pull request #174: Android: leave the volume to the system
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m56s
2026-08-21 05:50:06 +00:00
logan f26b44db08 docs(player): close three of the four gaps with a real device
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 9m5s
A Light Phone III (Android 14, SDK 34, arm64, Chrome 113 at 424x439)
was attached after the PR was opened, so what it listed as
unverifiable was checked rather than left as a caveat.

SystemOwnsVolume answers true on the device -- the build tag, the
constant, the field and the generated binding, end to end, which is the
one thing a source sweep only approximates and which nothing else here
compiles at all.  The control is absent in both mount points on the
real engine, and the transport measures 143px, exactly what the
desktop-headless "after" predicted.  A stored volume of 37 survives a
session that demonstrably rewrote the row.

The duck is the one that stays open, and now for a stated reason rather
than for want of hardware: the foreground service omits
setWillPauseWhenDucked from Oreo, so the framework attenuates us itself
and never sends AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -- the device logs
`requestAudioFocus() ... flags=0x0` saying so.  minSdk is 21, so that
path is live code on Android 5.0 to 7.1 and unreachable above it.
Asking for a modern phone will not test it.
2026-08-21 01:34:44 -04:00
logan b43172a60c docs(player): record who owns the volume, and what it gave back
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m47s
CLAUDE.md's volume paragraph ended "#64 asks for it to be gone on
Android outright, which is a platform question the frontend cannot
currently ask", which is no longer true -- it can, and the paragraph
now says why the answer is a capability rather than a viewport and what
that costs.  The mediacontrols entry gains the corollary: on that
platform "the user's level" is a constant, and the duck is the one
thing that may still move the output.

NOTES.md carries the measurements: the per-element budget at 424x439
before and after, the :host([hidden]) specificity trap, the fact that
the bar's centring survives the control going away, and what no tier
here could check.
2026-08-21 00:53:44 -04:00
logan 2be6fb3066 feat(player): draw no volume control where there is no volume
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not.  The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.

It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on.  index.css keeps
its phone rule, which is now about room and says so.

Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport.  The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].

Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing.  The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.

volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer.  Both of its tests were confirmed to fail on
the build before this.

Closes #64
Closes #172
2026-08-21 00:53:36 -04:00
logan 867ced8c81 feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework
mixes our stream against the device level, so a second control inside
the app moves something the user already moved.  Where that is true the
player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle
are refused, and nothing persists a level nobody chose: restore
remembers the stored value instead of applying it, and saveState writes
that same value back rather than recording the synthetic maximum.

Mute is in that list because it is a level of zero by another name --
and because with no control rendered it would be the one state on such
a platform the user could not get out of.

The predicate is named after the capability rather than the platform,
because that is what makes it testable.  Only platformOwnsVolume is
behind a build tag, in two files that declare nothing else; everything
else is decided against Player.systemVolume, a field a test sets either
way.  That is mediacontrols' split, with androidpayload.go's reasoning
for keeping the contract out of a tagged file, and the tagged pair is
covered by a source sweep since no tier here compiles both halves.

SetDuck is deliberately untouched: it applies its attenuation by
re-applying the *user's* level through setVolumeLocked, so pinning that
level to maximum leaves the offset arithmetic exactly as it was.  It is
the only thing that may still move the output on such a platform, and
TestSystemVolumeStillDucks is that property rather than a comment.
2026-08-21 00:53:21 -04:00
logan fd71ef53c5 Merge pull request 'The phone transport: three controls, sized for a thumb' (#173) from 59-slim-the-mini-player into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m5s
2026-08-21 04:14:39 +00:00
logan e3b64f9255 test(player): assert the desktop bar's size by mechanism, not by pixels
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m24s
CI / e2e (pull_request) Successful in 8m56s
WebKit draws the same button 36x24 where Chromium draws 33x21, so the
literal this pinned failed in CI on a build where nothing was wrong. A
button's box comes from the UA stylesheet when the author sets nothing,
and what each UA sets is its own business.

What must not happen is that *we* set something. So: `min-width` and
`min-height` compute to 0px, the font-size still equals that of a bare
button probed in the same page, and all five boxes are identical --
which is what says the desktop is neither sized context. Checked by
re-introducing the `font-size: inherit` regression, which it catches in
Chromium; the literal form could only be checked by hand.
2026-08-21 00:02:27 -04:00
logan c7e5a4f086 docs(player): record the phone transport, and four silent failures
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Failing after 9m38s
The model in CLAUDE.md beside the volume rule it qualifies; the
measurements and the four things that cost a cycle each in NOTES.md,
dated. Three of the four are invisible to every assertion in the repo:
a button not inheriting its font, a nested rule out-specifying a later
one, and art whose height is bounded by nothing.
2026-08-20 23:42:36 -04:00
logan f65822c4b2 test(player): pin the phone transport, and the desktop bar not moving
Ten tests, five of which fail on the build before this. The desktop
guard is meant to pass there -- that is its job, and it is the one that
caught a three-pixel regression nothing else could see.

`openTheQueue` moves to the fixtures, because hiding one button failed
ten tests in four files about the back stack and about layout: every
one of them opened the queue by clicking `#queue-button`, and so was
quietly asserting *which* route exists as well as what the queue does.
The route differs by width now and that is the feature.

Two smaller things. The play button is named for its action, so an
exact 'Play' waits out a fixture track -- 11.1s per test, passing by
luck, and it would have failed outright against LONG_TRACK. And the
"nothing playing" case clears the queue itself rather than trusting the
app not to have played anything: `make e2e` runs one long-lived app
across every spec (#168), which is how a deterministic bug first showed
up as a flake.
2026-08-20 23:42:35 -04:00
logan 32d4dc2c82 feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar.
They are not gone: all three are on the full-screen Now Playing view,
one tap away through the mini player's art, which is the "reachable
only from Now Playing" this issue asks for. #55 is what makes the queue
half safe -- it is a screen with an entry in the back stack now, rather
than a panel with no way out but the button being removed here.

Removing a control is only allowed because it is still reachable, which
is plan 018's matrix promise, so that is what the spec walks rather
than counting buttons. It found that the route did not exist in the
state that matters: `now-playing` renders two branches and the no-track
one had no `.expand` button on its placeholder, so with nothing loaded
there was no way to the full-screen view at all -- and once the queue
button left the bar, no way to the queue. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into, not a corner.

The favourite stays on the bar and was 18x14px, the smallest control in
the app, against the 48x48 art beside it.

One CSS trap, because it failed silently. The phone block is last in
index.css on purpose -- a media query adds no specificity -- but the
rule it overrides here is written *nested* inside `.bottom-bar`, so it
builds to a descendant selector one class more specific and a bare
`#queue-button` lost to it. Being last is not enough when the thing
above is more specific.

Closes #59
2026-08-20 23:42:34 -04:00
logan 218e4f5e99 feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was
33x21px -- in the bottom bar and on the full-screen view alike. #56
reports them as "the most important thing in the mobile app and they
are tiny", and that is the number behind it.

The context is a **property, not a media query**, and that is the whole
design. Everywhere else in this app a component states what it drops at
phone width itself, because a media query inside a shadow root is
answered by the viewport and that is the honest signal. Here the two
hosts want different answers at the *same* viewport: on a phone the bar
wants three controls sized for a thumb and now-playing-view wants five,
larger still. So the host says which context and the viewport says
which size band, and neither alone can express it.

Play/pause alone goes above the 44px floor. A row of five identical
squares says every action is equally likely, which is not true of play
-- "large play/pause, adequate prev/next" is the Direction, and a spec
caught that the first version had sized all three the same.

Two things that fail silently:

The desktop bar must not move, and a `<button>` does not inherit its
font from its parent -- the UA stylesheet gives it one. So a generic
`font-size: inherit` is not the no-op it reads as: it took every
desktop control from 33x21 to 36x24. The box rules take a zero fallback
and the font-size rules are scoped to the two contexts that set one.

And the art on now-playing-view overflowed its own box, drawing over
the header above and the title below, because `aspect-ratio: 1` with a
definite width derives a height that nothing bounds -- 60vh bounds the
viewport, not the room left over. `max-height: 100%`. Pre-existing;
found by reading a screenshot, which is the only tier that can see it.

What is left is #172: with the transport at 172px of a 439px screen the
art is a 39px sliver.

Closes #56
2026-08-20 23:42:15 -04:00
logan 56a5ff99fe Merge pull request 'The queue is a place while it covers the content' (#169) from 55-queue-as-a-screen into main
CI / check (push) Successful in 2m35s
CI / e2e (push) Successful in 8m41s
2026-08-21 03:00:45 +00:00
logan af4b28b0d7 docs(queue): record why the queue is not a detail view
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 8m49s
The measurement that decided it, dated, in NOTES.md -- the overlay's
rect against the main panel's, the three things that were genuinely
missing, and the computed containment of both candidate mounts. The
model itself goes in CLAUDE.md beside the overlay rule it extends.
2026-08-20 22:14:42 -04:00
logan 4ee5b4b473 test(queue): pin the back stack and the mount that was not taken
Nine tests, and the header says which of them reproduce the defect:
three do, and the other six cannot. "The entry is not orphaned" and "a
docked column is not in the stack" are both vacuously true of a build
that pushes no entry at all. That was established by reverting the
source and re-running, not assumed.

The containment assertion is the one worth reading twice. It asks
where the panel *is* rather than whether a menu is clipped, because
CI's Chromium and WebKit both have the Popover API -- so the symptom is
invisible here and a spec asserting "not clipped" is green on the
broken build. `.planning/NOTES.md` states the mechanism.

The rest assert the entry rather than `aria-expanded`, which is the
shell's own bookkeeping and was right throughout the defect: what has
to be true is that one back press closes the queue and the *next* one
navigates.
2026-08-20 22:14:42 -04:00
logan a70a7ed9eb fix(queue): size the queue screen's way out for a thumb
Measured at 424x439: the three header actions were 25x21px. That
matters more than it looks, because with the panel spanning the whole
width the scrim underneath it has no uncovered pixels at all -- so the
close button is the only pointer route out of a full-screen surface,
and it was below the 24x24 floor in one dimension.

Sized only in overlay mode. Inline these sit in a 320px column beside
the content, where a mouse is what reaches them and 44px of header is
44px the queue does not get.
2026-08-20 22:14:41 -04:00
logan de2cb2693a feat(queue): give an overlaid queue a place in the back stack
The queue's pixels were already right. Measured at the reference
device's 424x439, #24's overlay is 424x318 -- `.main-panel`'s rect
exactly -- so the `DETAIL_LOADERS` mount the issue's Direction asks for
would draw the same rectangle in the same place. What was missing was
the navigation model: opening the queue on Artists and pressing back
moved the page *underneath* to Albums and left the queue up, which is a
press that changes something the user cannot see and costs them their
place.

So the queue is a *place* exactly while it is an overlay, and a
*control* while it is a column. A column is a thing the user docked --
back must not undock it and a navigation must not take it away -- and
that reuses #24's computed mode rather than adding a breakpoint, so the
drag-resizable panel width keeps deciding it.

It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`, because there is
nothing to mount and moving it would cost something. `.main-panel > *`
computes `contain: content` under a `.main-panel` that does too, and
paint containment clips the `position: fixed` a `wa-popup` falls back
to on Chrome 113 (#60) -- so the detail-view mount would have broken
`queue-panel`'s working context menu on the one device this is about.
The panel's ancestry today is paint-free to `body`.

Two details that fail silently otherwise. The entry is unwound from the
panel's `open` attribute in the observer that already ran for
`aria-expanded`, not at each of the four ways out -- without that the
entry is orphaned and the *next* back press is the one that closes the
queue, which is this defect moved one press later. And the navigation
writes neither `dataset.activeView` nor `searchStore.setCurrentView`,
because both describe what is *in* the main panel and the queue covers
that panel without replacing it.

`now-playing-view`'s copy of the button went through the helper too: it
set `open` directly, so on a phone it produced exactly the queue with no
entry behind it that this removes.

Closes #55
2026-08-20 22:14:27 -04:00
logan 880adff12c Merge pull request 'Drop the phone's top bar; search becomes a button and a modal' (#167) from feat/57-drop-the-android-top-bar into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m17s
2026-08-21 00:18:33 +00:00
logan d6f7412e9d docs(shell): the phone has no top bar, and why the modal is a dialog
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m19s
CLAUDE.md's shell prose said the phone's header "controls shrink or
stand down"; there is no header there now. The search box's section
gains the modal and the four rules behind it, page-header gains the
count as the last thing to yield, and the top-bar-fit section gains
what happens below its own band.

NOTES.md gets the three measured facts, dated: `contain: paint` is why
a Web Awesome popup is clipped on Chrome 113 and why no tier here can
reproduce it, the arithmetic that cost the page header its count at
320px, and the shared long-lived e2e app that makes an absolute
coordinate a hidden assertion about background jobs.
2026-08-20 20:03:28 -04:00
logan 1ab767a317 feat(shell): take the top bar out of the phone's layout
The row is deleted from the grid template below 600px, not the header
hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest
vertical win the reference device has to give, and the reason the issue
asks for the row rather than for a smaller bar.

Each of the five things the bar held has somewhere else to be there:
nav-history is the platform's own back gesture and was already gone from
899 down, the job indicator is <job-band> (#62, which is what this was
blocked on), the search box is a modal opened from the view's own
header, the library filter is Settings -> Libraries, and the wordmark
stays where it is.

Three things are load-bearing.

**The header is visually hidden rather than display: none**, because
that h1 is the document's top-level heading and several pages have no
other one -- page-header renders no h1 when its heading is empty, and
Settings has no page-header at all. Its four controls are display: none
*inside* it, which is what keeps them out of the tab order: a
visually-hidden container is still focusable, and tabbing into a search
box nobody can see is worse than not having one.

**The fit pass stands down**, from the bar's computed position rather
than from a width. With the bar out of flow there is no content box to
measure children against, and a pass that ran would collapse the
wordmark on every resize and report success about a 1px box.

**top-bar-fit.spec.ts keeps 390 and asserts the stronger property.**
"Nothing hangs out of the bar" is trivially true of a bar with no row
and would pass on a build that merely broke it, so what that width asks
now is that the content starts where the row above it ends. Measuring
against the window instead would have been asserting "and no background
job is running", which that spec is not about and cannot arrange.

Closes #57
2026-08-20 20:03:20 -04:00
logan ac8f86eb00 fix(settings): give the library selection a home that is not the top bar
library-filter is the only control in the app that calls
setSelectedLibrary, and the phone already hid it with a comment saying
it was "reachable from the drawer's Settings". It was not: Settings adds,
removes, renames and scans libraries, and does not set the view filter,
which is a different thing -- it decides what Albums, Artists and Genres
show. A phone therefore inherited whatever a desktop session last chose
and could neither change nor see it, which is #24's sentence broken in
the band it was written for.

It is a second *placement* of the same component, not a second control,
and it is at every width rather than below 600px. A phone-only copy is
the cheaper answer and is the fault rather than the fix: "where do I
change which library I am browsing" having two answers by viewport is
exactly what one control in two places avoids.

Closes #148
2026-08-20 20:03:08 -04:00
logan 47bd9ef211 fix(header): let the count yield before an action is clipped
Adding the phone's search button to this header is 43px more than the
row has at 320px, which is a width the app promises and which
header-action-overflow.spec.ts asks about. Measured on Playlists there,
after the fit pass had already collapsed all three actions into "More
actions" and truncated the title to nothing: title 0, count 50, sort
143, search 40, More 38, five 12px gaps and 32px of gutters -- 363 in
320, with the More button ending 27px past the edge. That is an action
clipped, which is the exact defect this pass exists to prevent.

The count is what yields, last, because it is the only item on that row
that is neither an identity nor an action. The title yields first and
may ellipsis away entirely, since the navigation also says which page
you are on; the sort control and the buttons are each the only place
they are said. An empty page says it is empty in its empty state and a
full one is being looked at. With the count gone the header is 304 in
304, and the title comes back to 19px.

It is rendered and hidden with an attribute rather than returned as
`nothing`, for the reason the action buttons are: every pass starts
from all-visible and needs a node to un-hide, or the first 320px window
costs the count for the rest of the session.
2026-08-20 20:03:01 -04:00
logan b801fa533a feat(shell): make search a button and a modal where searching applies
The phone's top bar is about to go, and the search box is the one thing
in it that is an action rather than chrome. It becomes a button in the
row that already says which page you are on, opening a wa-dialog with
the real search box in it.

Three decisions worth the words.

**A wa-dialog, and that is a mechanism rather than a taste.** wa-popup
renders `<div popover="manual">` and feature-detects the Popover API,
falling back to `strategy: "fixed"` where there is none -- which is
Chrome 113, the reference device, since `popover` is Chrome 114. And
`position: fixed` escapes ancestor overflow but not `contain: paint`,
which `.main-panel` carries, so a popup-shaped search panel opened from
a view's header is structurally clipped on that device. `<dialog>` /
`showModal()` is Chrome 37 and uses the real top layer. No tier here can
see the difference -- CI's Chromium and WebKit both have the Popover
API -- so the component test asserts the *mechanism*, a native
`<dialog>` in the tree, rather than the symptom.

**An element, not a PageAction.** Two of the seven searchable views are
detail views with no page-header; they filter on the term and say so in
their own headers. Declaring search as an action would mean seven hosts
each writing it out, which is a second list of searchable views, and it
would put a phone mode for actions inside page-header, which that
component documents its refusal to grow. search-store's own map is the
condition, asked by one component placed three times.

**The modal carries the real search-bar**, so there is still one
debounce, one clear button and one view-scoped placeholder. Escape
closes it and *keeps* the term -- the input treats Escape as "clear the
search", which is right in a header where the box stays on screen and
wrong in a surface whose dismissal would then discard the search.
2026-08-20 20:02:50 -04:00
logan 8879192097 Merge pull request 'Show background jobs in the phone's layout, not a popover' (#166) from feat/62-jobs-as-a-notification into main
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 8m14s
2026-08-20 22:32:47 +00:00
logan f76ee96ac4 docs(jobs): the phone's band, and why it is in flow
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 8m4s
CLAUDE.md's jobs section said the header indicator is "the one view of
everything at once, from every page"; that is now true on a desktop
only, and the band is the phone's half.

NOTES.md takes the measurement that decided the shape -- an overlay
band at 424x439 is a lid, not a notification -- and the corollary about
which tier can see it: ui-test, tsc, lint and the Go suite all passed
on the broken version, and what failed was three e2e specs that have
nothing to do with jobs. Run the suite, not the spec you wrote.
2026-08-20 18:17:42 -04:00
logan 23f5a0c53a feat(shell): show background jobs in the phone's layout, not a popover
The header indicator is a disclosure anchored to a bar 3.25em tall on a
screen 439 CSS px tall, and it was reported as unreadable behind other
UI. Background work is the one thing a phone should not make you open
something to see, and #57 deletes the bar it hangs from and is blocked
on it having somewhere else to live. Below 600px the indicator stands
down and <job-band> takes over.

It is the existing job-panel at `kinds="*"`, so pause, cancel, Details
and the log come along, and so does applyJobControl.

**It is in the layout, not over it**, and that was measured rather than
assumed. The first version put the panel in notification-host's fixed
band: it renders correctly, sits on top and stays inside the viewport,
and is unusable -- at 424x439 a compact panel showing two jobs is
~216px of a 439px screen, drawn over the content and swallowing every
tap under it. Four e2e specs caught it, and none of them was about
jobs: two phone-shell journeys and the header's action menu, all
failing on clicks the band was intercepting. As a grid row above the
main panel it pushes instead, which is #24's one sentence deciding a
layout question -- a band that hides the app to say the app is busy has
traded the popover's fault for a worse one.

It renders nothing above 600px, from matchMedia rather than a media
query, because that decides whether the element exists: Settings
already holds four job-panels and a fifth answering for every kind is
bottom-nav's "resolved to 2 elements" trap again. index.css keeps it
display:none off the phone for a second reason -- an in-flow grid child
with no named area is auto-placed into one of the shell's rows, which
is what the skip link is absolutely positioned to avoid.

top-bar-fit's 390px case asserted the indicator was up, so that it
could not pass by measuring the idle case under another name. At phone
width it is now deliberately away, so the assertion takes the other
branch of the same rule -- the indicator is hidden, the band has the
row, and the bar still has nothing hanging out of it -- rather than
the width being quietly dropped from the list.

The report's own symptom is deliberately not asserted anywhere: it did
not reproduce in this tier. Measured at 424x439 the popover was neither
clipped nor covered, so a spec claiming a stacking fix would be
asserting something that was never true here. The spec says so.

Closes #62
2026-08-20 18:17:35 -04:00
logan 502b814a65 feat(jobs): let a panel answer for every kind, at either density
Three properties the phone's band needs, added here so it is the same
panel rather than a second job UI -- which is what keeps
`applyJobControl` and its "you will discard hours of downloading"
confirmation in the picture.

`kinds="*"` is every kind, which is what the header indicator was for.
Spelled as a star rather than taken as the meaning of an empty
attribute, because empty is what a typo and a dropped binding both
produce and "show everything" is the wrong thing to do by accident;
empty still shows nothing.

`density` is passed to `job-row`, whose `compact` variant its own
source calls "the popover density" -- which is exactly what the band
replaces. `full` stays the default, so the four settings call sites are
untouched.

`active-only` drops terminal rows. The band is in the layout, so a
finished row there holds the content down after the work is done;
Settings keeps them, because that is where "did the last scan work" is
asked and a finished row there dismisses itself.
2026-08-20 18:17:20 -04:00
logan c19a806298 Merge pull request 'Give the seek bar's interpolation interval one owner' (#165) from fix/53-seek-bar-never-moves into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 7m58s
2026-08-20 21:31:17 +00:00
logan 67eeb75e7b docs(android): the device can be driven, not just looked at
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m59s
The runtime call does not go over HTTP on Android — the WebView cannot
deliver a fetch() POST body to shouldInterceptRequest, so v3 routes
runtime calls through the addJavascriptInterface bridge. Two things
follow that cost an hour each before the v3 source was read:
`.playwright/init-events.js` does not transfer to the device (its
outbound half hooks fetch, and a POST to /wails/runtime answers
"missing object value" — which reads like a wrong payload and is the
interceptor getting no body at all), and hooking fetch from an eval is
too late on any platform because the bundle captured its reference at
module scope.

The recipe that does work goes in, along with how to get audio onto the
phone (scoped storage silently swallows a push into
/sdcard/Android/data/<pkg>/files, and the fixtures are 2 seconds long,
which is useless for watching a seek bar) and the permission dialog a
reinstall raises, which looks exactly like the app failing to start.

NOTES.md takes the #53 measurements: that its frontend is byte-identical
to the v0.3.1 the phone carries, that the symptom does not reproduce on
main in four scenarios, and that reverting only backend/player/ to
v0.3.1 reproduces #125 instead — with the shim that makes that a
ten-minute experiment rather than a full checkout.
2026-08-20 17:17:33 -04:00
logan fe1fbefee7 fix(player): give the seek bar's interval one owner
`handleInput()` called `stopProgress()` and mutated no reactive state,
so Lit scheduled no update, `updated()` never ran, and the tail of
`updated()` that restarts the interval never executed. Only a `change`
event or the next backend report could bring it back — so an `input`
that never commits froze the interpolation: a drag cancelled outside
the element, a pointer taken by a scroll, or a touch on the track
treated as a scrub, all ordinary gestures on a phone. While playing the
1 Hz report papered over it within a second; with reports not arriving
it was permanent.

The drag is `@state` now and `updated()` decides whether the interval
runs, so there is one place that knows. `handleChange` no longer starts
it directly for the same reason.

A flag set on `input` can strand, which would turn a stall of up to a
second into a permanent one — the failure this removes. `change` is the
ordinary end; `pointerup`/`pointercancel`/`touchend`/`touchcancel` on
the document are the ends that are not, attached with the drag and
dropped with it, because the pointer is routinely released outside the
element it started in.

The other half is that a report arriving mid-drag used to overwrite
`seekValue` and pull the thumb out from under the finger once a second.
It is skipped while dragging, and its seq is deliberately left
unrecorded so the first report after the drag still counts as fresh.

Three tests, all exercised against the fault: two fail on the old
component, and the third fails if the drag flag is left set — which is
the failure mode the fix introduces and the listeners exist to prevent.
Verified on the device too (Chrome 113): mid-drag the bar holds its
value and ignores reports, and on release it adopts the backend's real
position and resumes ticking.

Closes #164
2026-08-20 17:17:23 -04:00
logan de04339494 Merge pull request 'Android: install and launch the package the APK declares' (#163) from fix/159-android-task-app-id into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m27s
Build & publish Arch package / arch-package (push) Successful in 2m43s
Attach the desktop build to the release / linux (push) Successful in 57s
Sync Homebrew formula / sync-formula (push) Successful in 6s
2026-08-20 19:46:35 +00:00
151 changed files with 19407 additions and 1518 deletions
+5
View File
@@ -88,3 +88,8 @@ build/android/overlay.json
# Written by @semantic-release/changelog purely to carry the release notes
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
# and .planning/). Written by the scheduled backlog runs.
.pi/journal.md
.pi/schedule-prompts.json
+142
View File
@@ -0,0 +1,142 @@
---
description: Take on the next actionable backlog issue end to end, and stop
---
Take on exactly one issue from the YellowJacket backlog, end to end, and stop.
Repo: yonlu/yellowjacket at https://git.ljones.me — API base
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket, auth with
`-H "Authorization: token $GITEA_TOKEN"`. Default branch is `main`.
## 1. Orient before you pick
Read, in this order: `CLAUDE.md` (the architecture and the reasons behind
it), `.pi/journal.md` (what happened last), `.planning/NOTES.md` (what was
already considered and rejected), and `.planning/plans/active/`. Do not skip
this because the issue looks small — most of this codebase's traps are
written down in exactly one of those four places, and the ones that bite are
the ones you didn't read.
## 2. Pick the issue
List open issues. Choose the single highest-value one that is *actionable
right now*:
- Order by `Priority/Critical``High``Medium``Low`. Within a tier,
prefer `Reviewed/Confirmed`, then `Kind/Bug` over `Kind/Enhancement` over
`Kind/Feature`.
- Consult issue #73 (the roadmap) — if it sequences the candidates, that
ordering wins over the label ordering.
- **Skip** anything labelled `Status/Blocked`, `Status/In Progress`,
`Status/Abandoned`, `Reviewed/Won't Fix`, `Reviewed/Duplicate`,
`Reviewed/Invalid`, or already carrying an open PR.
- **Skip anything someone else is already on.** The label is not the only
claim, because a concurrent session may not have applied it — several pi
sessions run against this repo from separate worktrees under
`~/.paseo/worktrees/`. Run `git ls-remote --heads origin` and skip any
issue whose number or slug matches an existing branch (`60-…`,
`fix/<slug>`). A duplicated fix costs more than a skipped issue.
- **Skip** anything that cannot be verified without hardware you do not
have: physical-device Android behaviour (audio output, on-device file
writes, real gesture input). A browser at 424px is not a phone — see the
Chrome 113 section of `CLAUDE.md`.
- **Skip** intermittent-failure issues unless you can reproduce the failure
on demand within a few minutes. Chasing a 1-in-3 flake is an unbounded
task and does not belong in a scheduled run.
- If nothing qualifies, say so, do nothing, and stop. An empty run is a
correct outcome.
## 3. Claim it
Add `Status/In Progress` to the issue and comment that you are picking it
up. Then branch:
```
git fetch origin && git checkout -b <type>/<short-slug> origin/main
```
`<type>` matches the issue's `Kind` (`fix/`, `feat/`, `refactor/`, `test/`,
`docs/`, `ci/`). Branch from `origin/main`, never by checking out `main`
itself — this repo is worked from several git worktrees at once and `main`
is checked out in one of them, so `git checkout main` fails outright.
## 4. Do the work
Fix the issue that was reported and nothing else. Match the surrounding
code's style. Follow the constraints in `CLAUDE.md` rather than reasoning
from first principles — where it explains why something is shaped the way it
is, that shape is load-bearing and there is usually a test pinning it.
**Anything else you discover becomes a new issue, not a bigger diff.** File
it with the right `Area/`, `Kind/`, `Priority/` labels, describe the
symptom before the theory, and link it from your PR. Scope creep is the
failure mode this instruction exists to prevent.
If the work turns out to be materially larger than the issue implied, stop:
comment on the issue with what you found and what it would actually take,
remove `Status/In Progress`, push nothing, and end the run.
## 5. Verify — the right tier, not the cheapest one
Run `make generate` if you touched `.sql` or `.templ`, and `make bindings`
if you changed a bound Go signature. Then run what the change actually
demands:
- Go change → `make lint` and `make test` (both cover all three build
configurations).
- Frontend component or store → `make ui-test`.
- User-visible flow → `make e2e` against `make dev-headless`. **Check the
port first**: `ss -ltn | grep 34115`. If it is occupied, another worktree
is already running the app — do not start a second one and do not run
`make e2e`. Attaching to someone else's build produces a green result
about code that is not yours, which is worse than no result. Either
choose an issue that does not need this tier, or stop and say why.
- Anything cosmetic or layout-related → look at a screenshot. Several bugs
in this repo's history were invisible to every assertion and obvious in an
image.
A tier you skipped is a claim you did not check. If a tier fails for reasons
unrelated to your change, say so explicitly rather than quietly moving on.
## 6. Keep the documentation true
If you changed structure, behaviour, or a constraint, update `CLAUDE.md` in
the same commit. That file is this project's memory; a change that leaves it
describing the old shape is worse than no change. Append a short entry to
`.pi/journal.md` covering what you did, what you verified, and what you left
open.
## 7. Commit and open the PR
Conventional Commits, imperative subject, ≤72 chars, scope optional. The
body explains *why*. Push the branch — never push to `main`, never
force-push.
Open the PR:
```
curl -sS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls \
-d '{"head":"<branch>","base":"main","title":"<subject>","body":"<body>"}'
```
The body states: what the issue was, what you changed and why, **which
verification tiers you ran and their results**, anything you deliberately
did not do, and `Closes #<n>`.
Then wait for CI (`ci.yml`, jobs `check` and `e2e`) and report the result on
the PR. If it fails, read the log — `gitea_ci`'s `job_logs` 404s on this
Gitea build, so use
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for per-step
status and `GET /api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs` for
the log — and fix it. Two consecutive failed CI runs on the same cause: stop,
comment what you know on the PR, and leave it for a human.
**Do not merge.** Comment on the issue linking the PR, leave
`Status/In Progress` on, and end the run.
## Finally
Report in three lines: which issue you took, what state it is in
(PR open / CI green / stopped and why), and any issues you filed.
+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
@@ -515,6 +534,130 @@ Four things about it, each of which costs an hour if met cold:
script. Plug in over USB for anything longer than a couple of probes.
- **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered.
- **A reinstall resets the runtime permissions**, and the grant dialog
is a separate activity that takes focus — so the app is up, `am start`
reports "delivered to currently running top-most instance", and
`pidof` is empty because it never got to the foreground.
`dumpsys window | grep mCurrentFocus` naming
`GrantPermissionsActivity` is the tell. `adb shell pm grant
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
knowing before an hour is spent on it. The WebView cannot deliver a
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
calls through the `addJavascriptInterface` bridge instead: the
@wailsio/runtime installs a `customTransport` that calls
`window.wails.invokeAsync(id, payload)` and receives the answer on
`window._wailsAndroidCallback`. Two consequences:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`, which sees nothing here, and its
`call()` posts to `/wails/runtime`, which answers
`Invalid runtime call: missing object value` — the interceptor got the
URL with no body. Its *inbound* half is still right, because
`dispatchWailsEvent` is the entry point in every mode.
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
the bundle captured its reference at module scope, so a wrapper
installed afterwards records nothing. That is why the harness is an
`initScript` and not a step in a spec.
What works is to borrow the bridge, chaining the runtime's own callback
so its pending calls still resolve:
```js
const pending = new Map();
const prev = window._wailsAndroidCallback;
window._wailsAndroidCallback = (id, response, error) => {
if (!pending.has(id)) return prev && prev(id, response, error);
const p = pending.get(id); pending.delete(id);
const env = JSON.parse(response || "{}");
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
};
window.__yj = { call(name, args) {
return new Promise((resolve, reject) => {
const id = "yj" + Math.random().toString(36).slice(2);
pending.set(id, { resolve, reject });
window.wails.invokeAsync(id, JSON.stringify({
object: 0, method: 0, windowName: "",
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
clientId: window._wails.clientId,
}));
});
} };
```
That turns the device into a tier that can be *driven* rather than only
looked at — `__yj.call("player.Player.LoadFile", [path])` and
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
#53 was measured. Names are the Go ones (`GetTracks`, not
`GetAllTracks`); an unknown one comes back as a plain
`unknown bound method name`, so a wrong guess is loud.
**Getting audio onto the phone**: `adb push` into
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
files are not there — scoped storage. `/sdcard/Music/...` plus
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
plain path. The generated fixtures are **~2 seconds** each, which is
fine for a scan and useless for watching a seek bar, so synthesise a
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
**And the reason to bother: the phone is an engine, not a screen.** The
first device here renders in **Chrome 113** at 424x439 CSS px. Every
@@ -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
+836
View File
@@ -4242,3 +4242,839 @@ took another ~10 s to come up.
Harmless here (the emulator was up before anything used it) and a
straightforward race otherwise: `start` should wait for a device that
is an emulator, not for whatever `pick_device` returns. Filed as #162.
## The Android runtime transport is not HTTP (measured 2026-08-20)
Found while trying to drive the phone for #53. `wails3` routes runtime
calls through `addJavascriptInterface` on Android, not through
`/wails/runtime` — the WebView cannot deliver a `fetch()` POST body to
`shouldInterceptRequest`, which the v3 source says in as many words
(`application_android.go`, "The Android transport"). The runtime
installs a `customTransport` over `window.wails.invokeAsync(id,
payload)` and takes the answer on `window._wailsAndroidCallback`.
Two things follow, and both cost time before the source was read:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`; a POST to `/wails/runtime` answers
`Invalid runtime call: missing object value`, which reads like a
wrong payload shape and is actually the interceptor receiving a URL
with no body at all. The payload shape was right the whole time. Its
*inbound* half is still correct, because `dispatchWailsEvent` is the
entry point in every mode.
- **Hooking `fetch` from an `eval` is too late on any platform.** The
bundle captured its reference at module scope, so a wrapper installed
afterwards records nothing — which is exactly why the harness is an
`initScript`. Measured: zero calls captured while the app was
demonstrably making them.
The working recipe is in `android-tier.md`; it chains the runtime's own
callback rather than replacing it, so its pending calls still resolve.
This is what makes the device a tier that can be *driven*.
## #53's frontend is byte-identical to the build it was reported against (2026-08-20)
`git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/
frontend/src/store/player-store.ts` is **empty**; the whole diff in that
area is `backend/player/`. The phone carries the released `v0.3.1`, so
whatever #53 saw, the component was not what changed — and v0.4.0 is
where the player audit (#122#127) landed.
Measured on that phone, current `main`, with a synthesised 4-minute
track: the Now Playing seek bar tracks correctly when mounted
mid-playback (`seekValue` 28 of 240), when the view is opened before
playback starts, after a tap on the track, and across an activity
recreation (same pid, bar resumes at 30 → 35). The issue's stated
symptom did not reproduce in any of them.
Reverting **only** `backend/player/` to v0.3.1 — the frontend and
everything else at HEAD — does reproduce a real position defect on the
same device: six seconds into a 20-second file with no database row,
played after a 240-second one, the bar read **01:27 of 240**. That is
#125's stale `trackLengthMs` ("cleared only by UnloadTrack, so a file
with no row inherited the previous track's duration"), and it is fixed
at HEAD. Note the *shape* of it: the fraction is roughly right and the
absolute numbers are wrong, so it presents as a clock that lies rather
than as a handle that will not move.
The one-line experiment is worth remembering: v0.3.1's `backend/player`
compiles against HEAD with a single shim
(`SetPlaybackFinishedHandler` gained a `srcErr error` parameter), which
makes "did the backend fix cause this" a ten-minute question instead of
a full checkout.
## An overlay band is not a notification, it is a lid (measured 2026-08-20)
#62 asks for background jobs to become "a notification" on the phone,
and the app has exactly one notification surface, so the first version
of the fix put `<job-panel>` in `notification-host`'s band — which is
`position: fixed` under the header. It renders correctly, it is on top,
it is inside the viewport, and it is unusable.
At the device's 424x439 viewport a **compact** panel showing two active
jobs is ~216px — half the screen — drawn over the content, with
`pointer-events: auto` so it swallows every tap underneath. Nothing in
the component tier could see it. The e2e suite could: four specs failed,
and *none* of them was about jobs — two `phone-shell` journeys into the
full-screen Now Playing and `header-action-overflow`'s phone case, all
three because the band was intercepting taps meant for the app.
`<job-band>` is in the shell's grid instead, as a row between the top
bar and the main panel, so it **pushes**. That is #24's one sentence
("no action is ever unreachable at any supported size") deciding a
layout question: a band that hides the app in order to say the app is
busy has traded the popover's fault for a worse one.
Two things fell out of it worth keeping:
- **A finished row in flow is furniture.** The overlay could afford to
keep terminal jobs around; a row that holds the content down after
the work is done cannot. `job-panel` grew `active-only` for the band,
and Settings keeps finished rows because that is where "did the last
scan work" is asked.
- **`job-row` already had the right density.** `variant="compact"` is
described in its own source as "the popover density", which is
exactly what the band is replacing — 216px against 259px for the
same two jobs, and no per-job statistics that a phone has no room
for.
## The e2e suite is the tier that sees a shell regression (2026-08-20)
Worth stating because it decided how #62 was verified. The change is
one component, one stylesheet and one line of `index.html`; `make
ui-test` (955 tests) passed on the broken overlay version and so did
`tsc`, `lint` and the whole Go suite. The failure was three specs that
have nothing to do with jobs, failing on `click()` timeouts.
The corollary for anything that draws over the shell: **run the whole
e2e suite, not the spec you wrote.** A spec written for a feature
asserts the feature works; what a new overlay breaks is everything
else, and only the suite is looking at that.
## `contain: paint` is why a Web Awesome popup is clipped on the device (read 2026-08-20, applied 2026-08-21)
Recorded here because it outlives #57 and #60 both, and because the
next person to reach for a floating surface will reach for `wa-popup`.
`wa-popup` renders `<div popover="manual">` and feature-detects the
Popover API, falling back to `strategy: "fixed"` where there is none.
The reference device is Chrome 113 and `popover` is Chrome 114, so
every popup in the app takes the fallback there. `position: fixed`
escapes ancestor *overflow* but not `contain: paint`, which makes an
element a containing block for fixed descendants **and clips them**
and `index.css` puts `contain: layout style paint` on `.main-panel`
and on `div.sidebar`.
So the rule is: **a floating surface opened from inside the main panel
must be a `wa-dialog`, not a `wa-popup`,** because `<dialog>` /
`showModal()` is Chrome 37 and uses the real top layer. #57's search
modal is one on that ground alone; #60 is the same finding applied to
the six context menus.
The half that costs time is the second one. **No tier here can
reproduce the clip.** CI's Chromium and WebKit both have the Popover
API, so a popup is top-layered and correct, and a spec asserting "the
surface is not clipped" is green on the broken build. Assert the
*mechanism* — that there is a native `<dialog>` in the tree at phone
width — which is the one form of the question a browser here answers
honestly.
## Removing the phone's top bar cost the page header its count (measured 2026-08-21)
#57 deletes the `top-bar` grid row below 600px and puts a 40px search
button in `page-header` instead. That button is 43px more than the row
has at 320px, which is a width the app promises (WCAG 1.4.10 reflow,
and `header-action-overflow.spec.ts` asks about it).
Measured on Playlists at 320px, after the fit pass had already
collapsed all three actions into "More actions" and truncated the title
to nothing: title 0, count 50, sort 143, search 40, More 38, five 12px
gaps, 32px of gutters — **363 in 320**, with the More button ending
27px past the edge. So an *action* was clipped, which is the exact
defect #69 exists to prevent.
What yields is the **count**, last, after everything else. It is the
only item on that row that is neither an identity (the title, which the
navigation repeats) nor an action (the sort control and the buttons,
each the only place they are said). With it gone the header is 304 in
304 and the title even comes back to 19px.
Two things worth keeping:
- **The failure was found by the suite, not by the spec.** `make
ui-test` (964), `tsc` in both packages, `make lint`, `make test` and
the new `phone-search.spec.ts` were all green; what failed was
`header-action-overflow.spec.ts` at 320×600, which has nothing to do
with search. That is #62's lesson holding for a second change in a
row: anything that adds to or reflows the shell has a blast radius
the spec you wrote cannot see.
- **A collapsed thing has to still be in the DOM.** Returning `nothing`
from `renderCount()` would have taken the count away for the rest of
the session the first time a 320px window appeared, because
`measureFit` starts every pass from all-visible and needs a node to
un-hide. Same shape as the action buttons, which is where the pattern
was already written down.
## The e2e app is long-lived, so a staged job outlives the spec that staged it (measured 2026-08-21)
`make dev-headless` runs one app across every `make e2e` invocation, and
`/__test/emit` writes to a store that nothing clears. A first draft of
`phone-search.spec.ts` asserted the content starts at y=0 with the top
bar gone; it passed alone and failed in a suite run, because
`top-bar-fit.spec.ts` had staged a long-titled scan and `<job-band>` is
a real grid row whenever work is in flight.
The fix is not `beforeEach` cleanup — it is measuring the right thing:
the content starts where the **row above it** ends, which is true with a
job running and without one. An assertion against an absolute
coordinate was quietly also asserting "and no background job exists",
which is not something that spec is about or can arrange.
## The queue was already the right rectangle; what it lacked was an entry (measured 2026-08-21)
#55 asks for the queue to be "a real screen instead of a pop-open
sidebar", and its Direction asks for a `DETAIL_LOADERS` mount. Measured
against `880adff` at the reference device's real viewport (424x439),
with #24's overlay open:
| box | rect |
|---|---|
| `.main-panel` | 424 x 318 |
| `queue-panel` host | 424 x 318 |
| `.panel-content` | 424 x 318 |
| `.scrim` | 424 x 318, entirely underneath the panel |
So a detail-view mount would have drawn the same rectangle in the same
place. Three things were genuinely missing, and none of them is a
rendering:
- **Back navigated the page underneath and left the queue up.** Opened
on Artists, pressed back: `data-active-view` went `artists` ->
`albums`, `open` stayed `true`. A press that changes something the
user cannot see, and costs them their place.
- **The scrim has zero reachable pixels at phone width**, because
`panel-content` is `width: 100%` there. #24's tap-outside-to-close
does not exist on the device.
- The only pointer route out was a **25x21px** button.
The rule that followed is that the queue is a *place* exactly while it
is an overlay and a *control* while it is a column, which reuses #24's
computed mode rather than adding a breakpoint.
**The containment finding is the reason the Direction was not
followed.** Read off the running app rather than the stylesheet:
| element | computed `contain` |
|---|---|
| `queue-panel` (open, overlay) | `layout style` |
| `.content-area` | `layout style` |
| `.main-panel` | `content` |
| `.main-panel > *` (a view) | `content` |
`queue-panel` has a `wa-popup` context menu, and #60's finding is that
`position: fixed` escapes overflow but not paint containment on
Chrome 113. Its ancestry today is paint-free to `body`; a
`DETAIL_LOADERS` mount would have put it under two paint-containing
ancestors. **No tier here can see that** — CI's Chromium and WebKit
both have the Popover API — so the spec asserts the mechanism (the
panel is not under a paint-contained ancestor) rather than the
symptom. This is the second change in a row where the honest assertion
was about where an element *is* rather than how it *looks*.
One thing worth knowing about the spec: **three of its nine tests fail
on the build before the change and the other six cannot.** "The entry
is not orphaned" and "a docked column is not in the stack" are both
vacuously true of a build that pushes no entry at all. Reverting the
source and re-running is what established which were which, and the
file says so in its header rather than implying all nine reproduce.
## The phone's transport, and three things that only a screenshot or a stash could see (measured 2026-08-21)
#59 and #56 were done as one PR — argued on #73 first — because they are
the same row of pixels: one removes controls from the phone's bar and
the other enlarges what is left, and both are one property on
`player-controls`. Measured at 424x439 before:
| control | before | after |
|---|---|---|
| bar: shuffle / prev / play / next / repeat | 33x21 each | prev/next 44, play 56, shuffle+repeat moved |
| bar: favourite | **18x14** | 44x44 |
| bar: queue button | 33x29 | gone (#59) |
| Now Playing: all five | 33x21 each | 44, play 64 |
| desktop bar: all five | 33x21 | **33x21** |
Four things cost a cycle each and are worth keeping.
**A `<button>` does not inherit its font from its parent.** The UA
stylesheet gives it one, so `font-size: inherit` on a button is a
*change*, not a no-op: it took every desktop control from 33x21 to
36x24 by moving them from 13.3px to the shell's 16px. Nothing failed.
The only way it surfaced was measuring the baseline by stashing the file
and re-running.
**And the pixel it was first pinned with was the wrong assertion.** The
spec asserted the literal `'33x21'`, measured in Chromium — and WebKit
draws the same button **36x24**, so it failed in CI on a build where
nothing was wrong. A button's box comes from the UA stylesheet when the
author sets nothing, and what each UA sets is its own business. What
must not happen is that *we* set something, so that is what it asserts
now: `min-width` and `min-height` compute to `0px`, and the font-size
still equals that of a bare `<button>` probed in the same page. That
form catches the `font-size: inherit` regression in either engine —
checked by re-introducing it — and it is the same "assert the
mechanism" move `queue-as-a-screen.spec.ts` makes about containment.
It is also the second time in two sessions that **CI's WebKit was the
only tier that could see something**, which is the argument for checking
that step ran rather than trusting the run's conclusion.
**A rule at the bottom of `index.css` still loses to a nested rule
above it.** The phone block is last on purpose because a media query
adds no specificity — but `#queue-button` is written *nested* inside
`.bottom-bar`, so it builds to a descendant selector one class more
specific, and a bare `#queue-button { display: none }` in the phone
block did nothing at all. Silently: the button simply stayed. Nesting
adds specificity the source does not show.
**Removing a control moved the question of how you reach what is left,
and ten specs were quietly asserting the old answer.** Hiding the bar's
queue button failed ten tests in four files about the back stack and
about layout, every one of which opened the queue by clicking
`#queue-button`. `openTheQueue` in `e2e/support/fixtures.ts` is the
route *this viewport* offers, and the fix was to stop hard-coding one.
**And the route it takes did not exist in the state that matters.**
`now-playing` renders two branches, and the no-track one had no
`.expand` button — so with nothing loaded there was no way to Now
Playing, and once the queue button left the bar the queue was
unreachable outright. The queue is persisted across restarts, so this
is a state the app launches into, not a corner. It first appeared as a
*flake* (#168: the long-lived e2e app meant whether a track was loaded
depended on which spec ran first), which is worth remembering — a leak
made a deterministic bug look like a race.
## Now Playing does not fit a 439px screen, and #56 makes that visible (measured 2026-08-21)
Two separate things, and only the first is a defect.
**The art overflowed its own box and drew over the header and the
title.** It is `width: min(100%, 60vh); aspect-ratio: 1`, so its height
is derived from its width and bounded by nothing — 60vh bounds the
*viewport*, not the room left over, and those differ by all the chrome
above and below. `max-height: 100%` is the fix and shipped with #56.
Pre-existing: screenshotted on `main`. **Found by reading a screenshot,
which is the only tier that can see it** — nothing fails, the shell does
not overflow, and every control is still hittable.
**With that fixed, the art is a 39px sliver**, because the transport is
now 172px of a 439px screen. That is a consequence of #56 rather than a
fault in it, and it is filed as #172 with the per-element budget. #64
(no in-app volume on Android) is ~30px of pure gain there and #51 is the
umbrella; folding shuffle and repeat back onto the primary row was
considered and rejected — it buys 52px, leaves the art at 91px, and
costs a third arrangement of the same five buttons.
## The volume is not ours on Android, and the predicate could not be a width (measured 2026-08-21)
#64 asked for the in-app volume control to be absent on Android. Its
first Finding said `volume-control` "already stands down at narrow
widths", which was true of one of its two copies and is why the issue
had been read as nearly done. The bar's copy goes by width; the
full-screen view's copy was deliberately kept, with a comment saying a
slider does belong there.
**The crux was platform versus width, and three options were on the
issue.** What settled it is that the *backend* half of the same issue —
pin the level at 1.0 — makes a width rule wrong on the platform the
issue is about: an Android tablet at >=600px gets the bottom bar, and
the bar's slider would then move a level that is pinned. That is a
control that cannot act, which `library-status-indicator` already
settled is worse than none. The same rule is wrong the other way below
600px, where a narrow desktop window has no hardware keys.
So the frontend asks the player — `SystemOwnsVolume` — and the answer
is right at every width in both mount points. **The predicate is named
after the capability rather than the platform**, which is what makes it
testable: only `platformOwnsVolume` is behind a build tag, in two files
that declare nothing else, and everything else is decided against a
field a Go test sets either way. `frontend/test/components/
volume-ownership.test.ts` stubs the binding and so exercises the
*Android* rendering on an ordinary Linux runner; both of its tests were
confirmed to fail on the build before the change.
**Measured at 424x439, by flipping `platformOwnsVolume` to true in the
`!android` file and rebuilding** — the real binding, the real store, the
real component, everything except the tag:
| element | before | after |
|---|---|---|
| header | 48 | 48 |
| **album art** | **39** | **68** |
| title / artist / album | 63 | 63 |
| transport (seek + controls + volume) | **172** | **143** |
| — seek bar | 19 | 19 |
| — player-controls | 116 | 116 |
| — volume-control | 21 | **0** |
29px, which is the 21px control plus the 8px flex gap it stops drawing:
a gap is only painted between boxes, so `:host([hidden])` costs the
transport nothing rather than leaving a hole. That is #172's "~30px of
pure gain" confirmed, and the art is 74% larger. It is still the
second-smallest thing on the screen, which is #51's evidence.
Three smaller things worth keeping.
**`:host([hidden])` has to be written down.** The UA's `[hidden]`
rule is `display: none`, but `volume-control`'s own `:host` sets
`display: inline-flex` and outranks it — so setting `hidden` alone
hides nothing. Same family as the nested-`#queue-button` specificity
trap from the session before.
**Rendering `nothing` and hiding the host are two different
assertions**, and the component test makes both: an empty shadow root
is what stops a by-role or positional query finding a button that
cannot act, and `hidden` is what stops the host occupying space. Either
alone passes on a build that gets the other wrong.
**The bar's centring survives the control going away.** #23's outer
columns are the same `min()` expression rather than content-sized, so
at 900px with the volume gone the bar's centre, `audio-player`'s centre
and `player-controls`' centre are all 450 — checked, because "the
transport is centred with a slider bolted to one side" is the fault
that rule exists for and removing the slider is the obvious way to
re-break it.
**What no tier here can check**: the constant itself, and ducking
against a real audio-focus change. The first is a source sweep
(`TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`), the second is
`TestSystemVolumeStillDucks` against the arithmetic. Neither is a
device, and no device was attached.
### The device answered three of the four (measured 2026-08-21, TLP301 / Android 14 / SDK 34 / arm64, Chrome 113 at 424x439)
A Light Phone III was attached after the PR was opened, so what that PR
listed as unverifiable was re-checked rather than left as a caveat.
**The whole chain resolves on the device.** `__yj.call("player.Player.
SystemOwnsVolume", [])` answers `true` — build tag, `platformOwnsVolume`,
`Player.systemVolume` and the generated binding, end to end. That is the
one thing the source sweep only approximates, and it took a real arm64
device because nothing else here compiles the `android` file at all.
(`GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...` with
the NDK's clang compiles it in ~40 s and is worth running first; it
catches a type error but not a wrong constant.)
**The control is absent in both mount points**, on the real engine:
`.bottom-bar volume-control` is `hidden` with an empty shadow root, and
so is `now-playing-view`'s. Measured on the device, transport **143px**,
which is the figure the desktop-headless "after" predicted exactly. The
art is 75px there rather than 68 because the fixture's `.names` block is
one line shorter, not because anything differs.
**Nothing persists a level nobody chose, and this is the measurement
that took some care.** The default (50) surviving proves nothing, since
50 is also what a fresh row holds. So: force-stop, pull `yj.db`, set
`player_state.volume = 37`, push it back through
`run-as … dd` (a `cp` from `/sdcard` is refused — the app sandbox
cannot read it), relaunch, and drive a queue change to make the row be
rewritten. Reading it back **the WAL has to be pulled with it** — the
main file still showed the old `last_track_path` and reads as a write
that never happened. With `yj.db-wal` beside it: `last_track_path` is
the new track, so `saveState` ran, and `volume` is still **37**.
**The duck cannot be verified on this device, and now for a stated
reason rather than for want of hardware.** `WailsForegroundService`
builds its `AudioFocusRequest` without `setWillPauseWhenDucked` on
API >= 26, so the framework attenuates the stream itself and never
delivers `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. The device's own log
says so: `MediaFocusControl: requestAudioFocus() … AA=USAGE_MEDIA/
CONTENT_TYPE_MUSIC … req=1 flags=0x0` — no
`AUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS`. **`minSdk` is 21**, so the
Go-side duck is not dead code; it is reachable on Android 5.0 to 7.1
and on nothing newer. Any future "verify ducking on a device" needs one
of those, and asking for a modern phone will not do it.
Two smaller things from the same session.
**A fresh install downloads the real catalog, and it is 209px of the
screen while it does.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI but is real on a device, so the first
measurement taken was of a screen with `job-band` on it and the art at
**0px**. That is not a defect and not #172 — it is the environment.
`explore.Service.StopIndexBuild` and a relaunch is the clean state.
**The first-run wizard does not dismiss when a library appears by a
route other than its own** (#175) — it was still up, full-screen and
intercepting pointer events, after `AddLibrary` succeeded through the
binding, and was gone after a relaunch. Filed.
## The context menu was clipped on the device, and the fix needed four measurements nothing here could make (measured 2026-08-21, TLP301 / Chrome 113 / 424x439)
#60 had been diagnosed from the Web Awesome source and was right. What
the device added was the numbers, and three things the reading had not
reached.
**The clip, reproduced before any code was written.** Long-press on the
lowest visible track row at 424x439:
| | |
|---|---|
| viewport | 424x439 |
| `.main-panel` | 0 to **318**, computed `contain: content` |
| menu panel | 191 to **401**, 210px tall |
| clipped away | **83px, three of seven items** |
| `wa-popup` computed position | `fixed` |
| `HTMLElement.prototype.hasOwnProperty('popover')` | **false** |
| row height | **29px** (against a 44px floor and a 48px ask) |
A screenshot shows the menu sliced off flush with the mini player's top
edge. Both halves of the diagnosis are therefore measured, not inferred.
**"A dialog escapes containment" was the premise, and it was untested.**
Every dialog in this app is mounted in `index.html`, *outside*
`.main-panel` — so nothing here was evidence about a dialog opened from
inside a view, which is what this change needed. A probe `<dialog>`
appended to `track-list`'s shadow root and `showModal()`n paints to
y=439, over the mini player and the tab bar. A top-layer element's
containing block is the viewport, paint-contained ancestor or not.
Checking that first cost ten minutes and would have cost a rebuild.
**The UA stylesheet is the thing that makes a naive sheet look wrong.**
That same probe came out **354px wide on a 424px screen**, centred,
because a native `<dialog>` carries `max-width: calc(100% - 6px - 2em)`
and `margin: auto`. `max-width: none` and explicit margins are four
declarations that are pure undoing.
**A retry loop cannot win against a steal that happens later.**
`MenuKeyboard` focuses the first item and returns as soon as it lands;
`wa-dialog` then focuses `[autofocus]` or *itself* on the frame after
`showModal()`, and it cannot see our first item to prefer it — the
panel is slotted through `menu-surface`, so the dialog's own
`querySelector` stops at the `<slot>`. Measured: the sheet opened with
`document.activeElement` on the `<dialog>` and every arrow key went
nowhere. Lengthening the retry budget does not help, because the first
attempt *succeeds*. The surface announcing `menu-shown` after
`wa-after-show`, and the keyboard re-asserting, is the fix.
**The submenu was made worse before it was made better, and only a
measurement caught it.** `#playlist-submenu` is a
`placement="right-start"` flyout anchored to its row. Making the menu a
full-width sheet moved that anchor to x=0, so the flip put the playlist
picker at **x 182 to 0 — entirely off-screen**, and "Add to Playlist"
led nowhere at all. Before the change the anchor row started at x≈245
and the same flip landed it on screen. It is a `menu-surface` too now
and stacks as a second sheet. Two lessons: a change that moves an
anchor changes every flip decision downstream of it, and *the scope I
declared on the issue was wrong* — I had said I would measure the
submenu and file it, and the measurement said fix it.
**And the sweep found two call sites the conversion missed.** Twelve
were converted by hand; `menu-surface.test.ts` reads every source file
and fails on a `<wa-popup>` outside a three-file allowlist, which
immediately named `queue-panel`'s add-to-playlist popup (a real menu,
converted) and `now-playing`'s cover preview (a hover affordance in the
bottom bar — allowlisted, since a touch device never opens it and
nothing clips it). A thirteenth menu written as a bare popup would pass
every tier here and be clipped on the device, which is precisely why
the guard is a source sweep rather than a rendered assertion.
**What no tier here can see remains the clip itself.** This runner's
Chromium and CI's WebKit both have the Popover API, so the popup is
top-layered and correct and a "not clipped" assertion passes on the
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.
+890 -41
View File
File diff suppressed because it is too large Load Diff
+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 }
+146 -17
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
@@ -71,6 +76,19 @@ type Player struct {
// not something the user chose.
duckAmount float64
// systemVolume is what SystemOwnsVolume answers: the platform's own
// control is the only one, so ours neither acts nor persists. It is
// a field rather than the build constant read directly so that a
// test can exercise both sides on any machine. See systemvolume.go.
systemVolume bool
// storedVolume and storedMuted hold the persisted level as it was
// found at restore, for a platform whose volume we do not own: the
// maximum we then run at is not a level the user chose, so saveState
// writes back what it read rather than overwriting it.
storedVolume UserVolume
storedMuted bool
// trackLengthMs holds the authoritative track duration in
// milliseconds, sourced from the database (which uses the
// custom header parser). The go-mp3 decoder's Len() can be
@@ -156,6 +174,8 @@ func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
logger: logger,
db: db,
state: Stopped,
systemVolume: platformOwnsVolume,
storedVolume: DefaultUserVol,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
@@ -277,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() {
@@ -469,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}
@@ -875,6 +970,10 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return
}
p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged()
p.saveState()
@@ -923,6 +1022,10 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return nil
}
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
p.emitVolumeChanged()
p.saveState()
@@ -953,6 +1056,14 @@ func (p *Player) MuteToggle() error {
return errNoAudioFileLoaded
}
// Mute is a level of zero by another name, so it goes with the rest
// of the volume where the system owns it -- and it would be the one
// state on such a platform the user could not get out of, since with
// no control rendered there is nothing left to un-mute with.
if p.systemVolume {
return nil
}
speaker.Lock()
p.volume.Silent = !p.volume.Silent
speaker.Unlock()
@@ -1403,7 +1514,15 @@ func (p *Player) saveState() {
volume := int64(DefaultUserVol)
muted := false
if p.volume != nil {
switch {
case p.systemVolume:
// The maximum this platform runs at is not a level anybody
// chose, so it is not one to remember. Writing back what
// restore found keeps the row a description of the user's
// setting without needing a second query that omits the column.
volume = int64(p.storedVolume)
muted = p.storedMuted
case p.volume != nil:
volume = int64(p.getUserVolume())
muted = p.volume.Silent
}
@@ -1487,11 +1606,20 @@ func (p *Player) restoreStateLocked() {
}
}
vol := clampVolume(UserVolume(state.Volume))
p.setVolumeLocked(vol)
if p.systemVolume {
// Remembered, not applied: the device's keys are the volume
// control here, so the player runs wide open and hands the
// stored level back untouched at the next save.
p.storedVolume = clampVolume(UserVolume(state.Volume))
p.storedMuted = state.Muted
p.setVolumeLocked(MaxUserVol)
} else {
vol := clampVolume(UserVolume(state.Volume))
p.setVolumeLocked(vol)
if state.Muted {
p.volume.Silent = true
if state.Muted {
p.volume.Silent = true
}
}
// Restore last track if the file still exists.
@@ -1531,8 +1659,9 @@ func (p *Player) restoreStateLocked() {
}
p.logger.Info("Player state restored",
"volume", vol,
"muted", state.Muted,
"volume", p.getUserVolume(),
"muted", p.volume.Silent,
"systemVolume", p.systemVolume,
"trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds,
)
+42
View File
@@ -0,0 +1,42 @@
package player
// Who owns the volume, and what follows when it is not us.
//
// On Android the hardware keys *are* the volume control and the
// framework mixes our stream against the device level, so a second
// control inside the app is a slider that moves something the user
// already moved (#64). Where that is true the player's own level sits
// at maximum, nothing changes it, and nothing persists it.
//
// **The predicate is named after the capability, not the platform.**
// The frontend asks "is there a volume for me to control", which is a
// question about this build; asking "is this a phone" instead would
// key the answer to a viewport, and an Android tablet at 600px or more
// would then draw the bottom bar's slider over a level pinned at
// maximum -- a control that cannot act, which is the thing
// `library-status-indicator` already settled is worse than none.
//
// **Only `platformOwnsVolume` is behind a build tag**, in two files
// that declare nothing else. A tagged file is compiled by nothing
// `make lint` or `make test` runs and is untestable off a phone, which
// is the reasoning `mediacontrols/androidpayload.go` states for
// keeping its contract out of one -- so everything decidable here is
// decided against `Player.systemVolume`, a field a test sets either
// way, and the tag decides only what that field starts as.
//
// The one thing this must not disturb is ducking. `SetDuck` applies
// its attenuation by re-applying the *user's* level through
// `setVolumeLocked`, so pinning that level to maximum leaves the
// offset arithmetic exactly as it was: an OS asking us to get out of
// the way of a navigation prompt is not the user setting a volume, and
// it is the only thing that may move the output on such a platform.
// SystemOwnsVolume reports whether the platform's own control is the
// only volume control there is, so this app neither offers one nor
// remembers a level.
//
// It is bound: the frontend renders no `<volume-control>` when it is
// true, at any width.
func (p *Player) SystemOwnsVolume() bool {
return p.systemVolume
}
+11
View File
@@ -0,0 +1,11 @@
//go:build android
package player
// platformOwnsVolume is true on Android: volume is the device's, set
// with the hardware keys, and `mediacontrols`' Android handler
// implements no volume callback for the same reason.
//
// See systemvolume.go for why this constant is the whole of what a
// build tag decides here.
const platformOwnsVolume = true
+10
View File
@@ -0,0 +1,10 @@
//go:build !android
package player
// platformOwnsVolume is false everywhere but Android: a desktop mixer
// is per-application, so our level is the one the user reaches for.
//
// See systemvolume.go for why this constant is the whole of what a
// build tag decides here.
const platformOwnsVolume = false
+215
View File
@@ -0,0 +1,215 @@
package player
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/database"
)
// pinnedPlayer is a player on a platform whose volume belongs to the
// device. The field is set rather than the build constant read,
// because the constant is true on exactly one platform and no tier
// here runs on it -- see systemvolume.go.
func pinnedPlayer(t *testing.T, db *database.DB) *Player {
t.Helper()
p := NewPlayer(slog.Default(), db)
p.systemVolume = true
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
return p
}
// TestSystemVolumeRefusesEveryWayToChangeTheLevel is the first half of
// #64: where the device owns the volume, ours sits at maximum and none
// of the three routes to a level moves it. Mute is in that list
// because it is a level of zero by another name, and because with no
// control rendered it is the one state on such a platform there would
// be nothing to get out of.
func TestSystemVolumeRefusesEveryWayToChangeTheLevel(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
if !p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = false on a pinned player")
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("starting volume = %d, want %d", got, MaxUserVol)
}
p.SetVolume(20)
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after SetVolume(20) = %d, want %d", got, MaxUserVol)
}
if err := p.ChangeVolume(-30); err != nil {
t.Fatalf("ChangeVolume: %v", err)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after ChangeVolume(-30) = %d, want %d", got, MaxUserVol)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if p.volume.Silent {
t.Error("MuteToggle silenced a player whose volume the system owns")
}
}
// TestAnUnpinnedPlayerStillChangesItsVolume is the other side of the
// same switch. Without it the test above passes on a player that
// refuses everything, which is what a mis-wired field would produce.
func TestAnUnpinnedPlayerStillChangesItsVolume(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
if p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = true off Android")
}
p.SetVolume(20)
if got := p.getUserVolume(); got != 20 {
t.Errorf("volume after SetVolume(20) = %d, want 20", got)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if !p.volume.Silent {
t.Error("MuteToggle did not silence an ordinary player")
}
}
// TestSystemVolumeStillDucks is the issue's second Finding, made a
// test: pinning the user's level must leave the OS's attenuation
// working, because a duck is not a volume the user chose and is the
// only thing that may move the output on such a platform.
func TestSystemVolumeStillDucks(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
open := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= open {
t.Errorf(
"ducked output = %v, want less than %v", p.volume.Volume, open,
)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("user volume while ducked = %d, want %d", got, MaxUserVol)
}
// A refused SetVolume must not disturb the offset either: it
// returns before setVolumeLocked, which is what re-applies it.
ducked := p.volume.Volume
p.SetVolume(10)
if p.volume.Volume != ducked {
t.Errorf(
"output after a refused SetVolume = %v, want %v",
p.volume.Volume, ducked,
)
}
p.SetDuck(false)
if p.volume.Volume != open {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, open)
}
}
// TestSystemVolumeWritesBackTheLevelItFound is the rest of the
// Direction: "make sure nothing writes a persisted volume from that
// platform". The maximum the player runs at is synthetic, so saving
// must not record it over whatever the row already said.
func TestSystemVolumeWritesBackTheLevelItFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// A level set by some earlier, unpinned session.
writer := NewPlayer(slog.Default(), db)
writer.volume = &effects.Volume{Base: 2}
writer.setVolumeLocked(30)
writer.SaveState()
p := pinnedPlayer(t, db)
p.RestoreState()
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("restored volume = %d, want %d (the level is pinned)", got, MaxUserVol)
}
if p.volume.Silent {
t.Error("restore muted a player whose volume the system owns")
}
p.SaveState()
state, err := db.Queries.GetPlayerState(db.Ctx)
if err != nil {
t.Fatalf("GetPlayerState: %v", err)
}
if state.Volume != 30 {
t.Errorf("persisted volume = %d, want 30 (untouched)", state.Volume)
}
}
// TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps the
// source, because the pair of tagged files is the one thing here no
// tier compiles both halves of: `make lint` and `make test` build the
// `!android` side only, so a deleted or edited android file fails
// nothing until somebody has a phone in their hand.
func TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform(t *testing.T) {
t.Parallel()
want := map[string]string{
"systemvolume_other.go": "const platformOwnsVolume = false",
"systemvolume_android.go": "const platformOwnsVolume = true",
}
tags := map[string]string{
"systemvolume_other.go": "//go:build !android",
"systemvolume_android.go": "//go:build android",
}
for name, decl := range want {
src, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if !strings.Contains(string(src), decl) {
t.Errorf("%s does not declare %q", name, decl)
}
if !strings.Contains(string(src), tags[name]) {
t.Errorf("%s does not carry %q", name, tags[name])
}
}
}
+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
}
+204
View File
@@ -0,0 +1,204 @@
import { test, expect } from '../support/fixtures.js';
/**
* #62. On a phone, background work is shown in the notification band
* and the header indicator stands down.
*
* The report was that the indicator's popover "is obscured by other UI,
* so it cannot be read while jobs run". Worth saying plainly: **that
* symptom did not reproduce in this tier.** Measured at the device's
* own 424x439 viewport, the popover was neither clipped nor covered
* `elementFromPoint` at its centre returned the indicator at every
* width tried. So this is not a fix for a stacking bug, and a spec
* asserting one would be a spec asserting something that was never
* true here.
*
* What is true regardless, and is what these assert:
*
* - a popover is a **disclosure**, and it is anchored to a bar 3.25em
* tall on a screen 439px tall. Background work is the one thing a
* phone should not make you open something to see.
* - #57 deletes that bar and is *blocked on this issue*, because the
* indicator needs somewhere else to live first. Somewhere else is
* the band, and the test that matters for #57 is that the bar no
* longer holds the indicator at all.
*
* This is the media-query tier by necessity: a query inside a shadow
* root is answered by the viewport, and `notification-host` decides
* whether the panel *exists* from `matchMedia`. The component tier
* cannot set either.
*/
type Page = import('@playwright/test').Page;
const JOBS = [
{
id: 'phone:scan',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music',
current: 40,
total: 100,
caps: { pausable: true, cancellable: true },
},
{
id: 'phone:idx',
kind: 'index-build',
state: 'running',
title: 'Building the search index',
current: 2,
total: 9,
caps: { pausable: true, cancellable: true },
},
];
/** The panel the band renders. Playwright's CSS engine pierces open
* shadow roots, which is what keeps this one line. */
const bandPanel = (page: Page) => page.locator('job-band').locator('job-panel');
const PHONE = { width: 424, height: 439 };
const DESKTOP = { width: 1100, height: 800 };
test.describe('background jobs on a phone', () => {
test('are shown in the band, without opening anything', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
// Both jobs, drawn by real `job-row`s -- asking the rows what they
// hold rather than reading the panel's text, which would pass
// whether or not a row rendered. Playwright's CSS engine pierces
// open shadow roots, which is what makes this one line;
// `querySelectorAll` does not, and stops at `job-panel`.
await expect(bandPanel(app).locator('job-row')).toHaveCount(2);
await expect(
bandPanel(app).locator('job-row').first(),
).toContainText('Scanning Music');
});
/**
* The #57 assertion. Not "the indicator is invisible" that could be
* true because the bar overflowed but that the shell's own rule
* puts it away at this width.
*/
test('leave the top bar, which is what #57 is waiting for', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
await expect(app.locator('job-indicator')).toBeHidden();
});
/**
* The property the first attempt at this got wrong, so it is the one
* worth pinning: the band is **in the layout**, not over it.
*
* A fixed band reads fine in a screenshot and is unusable -- at
* 424x439 a compact panel is ~200px of a 439px screen and it covers
* what is under it. Four specs failed on that version, two
* phone-shell journeys and the header's action menu, because the
* panel was intercepting the taps. So: nothing of the app is
* underneath it, and the main panel starts below it.
*/
test('push the content down rather than covering it', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
const before = await app
.getByTestId('main-content')
.evaluate((el) => el.getBoundingClientRect().top);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const after = await app.evaluate(() => {
const band = document.querySelector('job-band') as HTMLElement;
const main = document.querySelector(
'[data-testid="main-content"]',
) as HTMLElement;
const b = band.getBoundingClientRect();
const m = main.getBoundingClientRect();
// What the browser reports at the band's own centre. If this is
// anything but the band, the band is sitting on top of it.
const hit = document.elementFromPoint(
Math.round(b.x + b.width / 2),
Math.round(b.y + b.height / 2),
);
return {
mainTop: m.top,
bandBottom: b.bottom,
withinViewport: b.bottom <= window.innerHeight + 0.5,
hit: hit?.tagName.toLowerCase() ?? null,
};
});
expect({
pushed: after.mainTop > before,
mainClearsBand: after.mainTop >= after.bandBottom - 0.5,
withinViewport: after.withinViewport,
hit: after.hit,
}).toEqual({
pushed: true,
mainClearsBand: true,
withinViewport: true,
hit: 'job-band',
});
});
/**
* A running job repaints several times a second. The stack it sits
* beside is `role="status" aria-live="polite"`, and a progress bar
* inside a live region is a screen reader reading a number out over
* and over so the two are siblings in the band rather than one
* list, and this is what says so.
*/
test('are not inside the live region they sit beside', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const insideLiveRegion = await app.evaluate(() => {
const band = document.querySelector('job-band');
// Neither the band itself nor anything it is nested in may be a
// live region -- `closest` answers both at once.
return !!band?.closest('[aria-live]') || band?.hasAttribute('aria-live');
});
expect(insideLiveRegion).toBe(false);
});
/**
* `bottom-nav` rendering its duplicate `<app-sidebar>` unconditionally
* broke 30 specs with "resolved to 2 elements" on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so
* a fifth that answers for *every* kind is the same trap which is
* why the band decides from `matchMedia` whether the element exists
* rather than hiding it with CSS.
*/
test('do not leave a second panel behind on a desktop', async ({
app,
testctl,
}) => {
await app.setViewportSize(DESKTOP);
await testctl.emit('JobsChanged', JOBS);
await expect(app.locator('job-indicator')).toBeVisible();
await expect(bandPanel(app)).toHaveCount(0);
});
});
-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();
});
});
+209
View File
@@ -0,0 +1,209 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The album page on a phone (#66).
*
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
* that spec asserts the *shell* needs no sideways scrolling, and the
* shell was correct throughout `body.scrollWidth === clientWidth`
* while `explore-album-details` itself measured 443 inside a 424px box
* and clipped two of the album's three primary actions with its own
* `overflow: hidden`. So the measurement here is **per control against
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
* needed for the same reason.
*
* The other half is the scroll: the page was a fixed header over a
* scrolling tracklist, so at the reference device's 424x439 the header
* owned 253 of the panel's 318px and the list scrolled in the 64 that
* were left. It is one scroll container below 600px, which is a
* property of the *host* rather than of `.content`.
*
* The engine is the caveat this tier cannot close: the reference device
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
* and a scroll container are nowhere near that engine's documented gaps
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
* at that size in Chromium" is not evidence about the phone.
*/
/** The phone this was measured on, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
const details = (page: Page) => page.locator('explore-album-details');
/** The page's own boxes, read from inside its shadow root. */
const geometry = (page: Page) =>
page.evaluate(() => {
const host = document.querySelector('explore-album-details');
const sr = host?.shadowRoot;
if (!host || !sr) return null;
const box = (sel: string) => {
const el = sr.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { width: Math.round(r.width), right: Math.round(r.right) };
};
const content = sr.querySelector('.content');
return {
hostWidth: host.clientWidth,
hostScrollWidth: host.scrollWidth,
// The host is the scroller below 600px, so the page is taller
// than its box rather than the tracklist being a window inside it.
hostScrolls: host.scrollHeight > host.clientHeight,
contentScrolls: content
? content.scrollHeight > content.clientHeight
: null,
header: box('.album-header'),
play: box('[data-testid="album-play"]'),
shuffle: box('[data-testid="album-shuffle"]'),
queue: box('[data-testid="album-queue"]'),
title: (() => {
const el = sr.querySelector('.album-title-text');
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
})(),
};
});
test.describe('the album page on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await openFirstAlbum(app);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
});
test('keeps every action inside its own box', async ({ app }) => {
const geo = await geometry(app);
expect(geo).not.toBeNull();
// "Shuffle album" ended at x=443 in a 424px component and could not
// be reached by any gesture; "Add to queue" at 440.
for (const action of ['play', 'shuffle', 'queue'] as const) {
expect(
geo?.[action],
`${action} is rendered`,
).not.toBeNull();
expect(
geo?.[action]?.right ?? 0,
`${action} ends inside the page`,
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
}
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
expect(geo?.header?.width).toBe(geo?.hostWidth);
});
test('gives the title the row rather than one glyph of it', async ({
app,
}) => {
// `.album-info` was squeezed to 112px beside the art, so an album
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
// and was shrinking as asked — the row had to stack.
expect(await geometry(app).then((g) => g?.title)).toBe(true);
});
test('scrolls as one page, with the header scrolling away', async ({
app,
}) => {
const before = await geometry(app);
expect(before?.hostScrolls).toBe(true);
expect(before?.contentScrolls).toBe(false);
const headerTop = () =>
app.evaluate(
() =>
document
.querySelector('explore-album-details')
?.shadowRoot?.querySelector('.album-header')
?.getBoundingClientRect().top ?? 0,
);
expect(await headerTop()).toBeGreaterThanOrEqual(0);
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
// programmatic scrolling, so a probe that assigns it passes on the
// build this exists to fail.
await details(app).hover();
await app.mouse.wheel(0, 250);
await expect.poll(headerTop).toBeLessThan(-100);
});
test('is the desktop arrangement again above the breakpoint', async ({
app,
}) => {
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: one component with two
// arrangements, not a phone-only copy.
await expect
.poll(async () => (await geometry(app))?.hostScrolls)
.toBe(false);
const arrangement = await app.evaluate(() => {
const sr = document.querySelector('explore-album-details')?.shadowRoot;
const header = sr?.querySelector('.album-header');
const content = sr?.querySelector('.content');
return {
direction: header ? getComputedStyle(header).flexDirection : null,
contentOverflow: content ? getComputedStyle(content).overflowY : null,
};
});
expect(arrangement.direction).toBe('row');
expect(arrangement.contentOverflow).toBe('auto');
});
});
/** Albums → the second card, which navigates to the album page. */
async function openFirstAlbum(app: Page): Promise<void> {
// Below 600px the sidebar is gone; the tab bar is the navigation.
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
// Dispatched rather than clicked: the card lives in a virtualizer
// inside a shadow root, and Enter expands the dropdown instead.
await app.evaluate(() => {
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card')[1]
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expect(
details(app).locator('[data-testid="album-play"]'),
).toBeVisible();
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
+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);
});
});
+136
View File
@@ -0,0 +1,136 @@
import {
test,
expect,
callBinding,
resetEvents,
waitForEvent,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The phone's progress line (#58).
*
* The component tier already pins what the line *says* that it
* renders the backend's reported position and never a count of its own.
* What only a real shell can answer is **where it is**: the issue asks
* for a line on the border between the mini player and the tab bar, and
* "on the border" is two adjacencies in a grid that no component-level
* render has around it.
*
* It also asserts the line is not there on a desktop, which is the
* other half of the same fact: above 600px there is no tab bar for it
* to sit on the border of, and the bar carries a real seek bar.
*/
type Rect = { x: number; y: number; width: number; height: number };
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const DESKTOP = { width: 1280, height: 800 };
async function rectOf(app: Page, selector: string): Promise<Rect | null> {
return app.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}, selector);
}
/**
* Put the 90-second fixture on and wait for the first position report.
*
* The long track rather than any track: every other fixture is 2-6
* seconds, which is shorter than the time this spec takes to measure
* three rectangles.
*/
async function play(app: Page): Promise<void> {
const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>(
app,
'library.Library.GetTracks',
[0],
);
// `TrackName`, not `Title`: that is what the library model calls it.
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
expect(long, `no fixture track named ${LONG_TRACK}`).toBeTruthy();
await callBinding(app, 'queue.Queue.Clear');
await resetEvents(app);
await callBinding(app, 'queue.Queue.SetQueue', [
[long!.FilePath],
0,
false,
NO_QUEUE_SOURCE,
]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
await waitForEvent(app, 'PlaybackPositionChanged', { timeoutMs: 15_000 });
}
test.describe('the progress line sits on the border between the bars', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await play(app);
});
/*
* Every test here starts a LONG_TRACK and the suite is workers: 1,
* fullyParallel: false against one long-lived app so without this
* the four phone-* specs that follow alphabetically inherit a playing
* queue. phone-transport.spec.ts records where that lesson came from:
* the fault first showed up as a flake in a spec about something else.
*/
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize(DESKTOP);
});
test('spans the width, between the mini player and the tab bar', async ({
app,
}) => {
const line = await rectOf(app, 'player-progress-line');
const bar = await rectOf(app, '.bottom-bar');
const nav = await rectOf(app, 'bottom-nav');
expect(line, 'no progress line on the phone').not.toBeNull();
expect(bar).not.toBeNull();
expect(nav).not.toBeNull();
// A border, not a band: 2px, the full width, and touching both.
expect(line!.height).toBeCloseTo(2, 0);
expect(line!.width).toBeCloseTo(bar!.width, 0);
expect(line!.y).toBeCloseTo(bar!.y + bar!.height, 0);
expect(nav!.y).toBeCloseTo(line!.y + line!.height, 0);
});
/**
* It is 2px on the top edge of the tab bar, which is exactly where a
* thumb aiming at a tab lands. A line that sometimes seeks is worse
* than one that never does, so it must take no part in hit testing
* at all.
*/
test('takes no taps', async ({ app }) => {
const line = await rectOf(app, 'player-progress-line');
const hit = await app.evaluate(
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? '',
{ x: line!.x + line!.width / 2, y: line!.y + 1 },
);
expect(hit).not.toBe('PLAYER-PROGRESS-LINE');
});
test('is not there on a desktop', async ({ app }) => {
await app.setViewportSize(DESKTOP);
await expect(app.locator('player-progress-line')).toBeHidden();
});
});
+322
View File
@@ -0,0 +1,322 @@
import { test, expect } from '../support/fixtures.js';
/**
* #57. Below 600px the top bar is not in the layout, and search is a
* button that opens a modal on the pages where searching means
* anything.
*
* **This is the tier that can answer it, with one honest exception.**
* The shell's breakpoints are media queries, which the component tier
* cannot set so whether the bar is a grid row, and whether a header
* grows a search button, is a question for a real viewport. What this
* tier *cannot* answer is the reason the surface is a `wa-dialog`:
* #60 read out of the Web Awesome source that `wa-popup` falls back to
* `position: fixed` where there is no Popover API (Chrome 113, the
* reference device) and that `.main-panel`'s `contain: paint` clips a
* fixed descendant. Chromium and WebKit here both have the Popover API,
* so a popup is top-layered and correct, and **an assertion that the
* modal is not clipped would pass on the broken build.** The mechanism
* is asserted in `frontend/test/components/search-dialog.test.ts`
* instead, where "is there a native <dialog>" is a question a browser
* can answer without lying.
*
* **And it is measured per element.** `layout-overflow.spec.ts` asks
* whether the *shell* needs sideways scrolling and was green throughout
* the defect it is named for; the win this issue is for is vertical and
* belongs to one element, so it is that element's box that is read.
*/
type Page = import('@playwright/test').Page;
/** The reference device's own viewport, and a common small phone. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
/**
* Where the top bar is, and how much of the screen it costs.
*
* `contentTop` is measured against the *jobs band* rather than against
* the window, because that band is a real grid row whenever work is in
* flight (#62) and the app under these specs is long-lived a job
* staged by another file is still in the store. Measuring against zero
* makes this assertion say "and no background job is running", which is
* not what it is for and is not something it can arrange.
*/
const barBox = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band');
const cs = getComputedStyle(bar);
return {
position: cs.position,
height: Math.round(bar.getBoundingClientRect().height),
/** Where the content starts, and where the row above it ends. */
contentTop: Math.round(main.getBoundingClientRect().top),
aboveBottom: Math.round(band?.getBoundingClientRect().bottom ?? 0),
};
});
test.describe('the phone has no top bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* The vertical win, measured rather than asserted by the absence of
* an element: `display: none` on the header would satisfy "the bar is
* hidden" while leaving a 3.25em grid row exactly where it was.
*/
test('gives the row back to the content', async ({ app }) => {
const box = await barBox(app);
// Out of flow, so it takes no row — and 1px rather than 0, because
// it still carries the document's h1.
expect(box.position).toBe('absolute');
expect(box.height).toBeLessThanOrEqual(1);
// The content starts where the row above it ends, and there is no
// row above it but the jobs band. On `main` at the time of writing
// the content started 52px down from that point.
expect(box.contentTop).toBe(box.aboveBottom);
});
/**
* The wordmark yields its width and not its existence, which is the
* rule `top-bar-fit.ts` already lives by one band up: with the bar
* gone, `display: none` would take this document from one top-level
* heading to none on every page whose own header has no h1
* Settings has no `page-header` at all.
*/
test('still has a top-level heading', async ({ app }) => {
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And its four controls are gone from the tab order, not merely from
* sight. A visually-hidden container is still focusable, and tabbing
* into a search box nobody can see is worse than not having one.
*/
test('leaves nothing in the bar to tab into', async ({ app }) => {
for (const tag of [
'nav-history',
'library-filter',
'search-bar',
'job-indicator',
]) {
await expect(app.locator(`header.top-bar ${tag}`)).toBeHidden();
}
const focusable = await app.evaluate(
() =>
document
.querySelector('header.top-bar')!
.querySelectorAll('input, select, button, a[href]').length,
);
// Nothing in the bar is *rendered*, so nothing in it can be
// focused; the controls are display:none, which takes their own
// shadow content with them.
expect(focusable).toBe(0);
});
});
test.describe('search on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is a button in the view that can be searched', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
// Scoped to the view: every cached primary view holds a
// `page-header`, and an unscoped testid is `bottom-nav`'s
// "resolved to 2 elements" trap again.
const trigger = app.locator('track-list page-header search-trigger button');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-label', 'Search tracks');
});
/**
* The whole journey, which is the thing the issue asks for: a button,
* a modal, and the results on the page behind it saying what they are
* showing.
*/
test('opens a modal, filters the page, and says so', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await app.locator('track-list page-header search-trigger button').click();
const dialog = app.getByTestId('search-dialog');
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
// so the element carrying the testid always reports hidden — what
// is visible is the native `<dialog>` inside it. That awkwardness
// is written down in CLAUDE.md and is why the assertion that this
// is really up is the role query below.
await expect(dialog).toBeAttached();
// Named, which `getByRole` can answer and the a11y snapshot cannot
// — the snapshot never prints a dialog's name, named or not. This
// is also the assertion that the dialog is genuinely showing.
await expect(
app.getByRole('dialog', { name: 'Search tracks' }),
).toBeVisible();
// Scoped: the header's own box is still in the document, hidden.
// This is the one moment there are two `search-input`s.
await dialog.getByTestId('search-input').fill('aurora');
// Enter hands the screen back, because the results are the page.
await app.keyboard.press('Enter');
await expect(dialog).not.toBeAttached();
// Polled: the box debounces by 150ms, so reading the page once
// straight after closing the dialog can capture the state before
// the term ever reached the store.
await expect
.poll(() =>
app.evaluate(
() =>
document
.querySelector('[data-testid="main-content"] track-list')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim() ?? '',
),
)
.toMatch(/matching.*aurora/);
// And the button says the search is on, in its name rather than
// only in its colour.
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveAttribute('aria-label', /aurora/);
// Leave the app as the next spec expects to find it.
await app.locator('track-list page-header search-trigger button').click();
await app.getByTestId('search-dialog').getByTestId('search-input').fill('');
await app.keyboard.press('Escape');
});
/**
* Two of the seven searchable views have no `page-header` they are
* detail views that filter on the term and say so in their own
* headers. A trigger placed only in `page-header` would leave them
* with a search they can show and no way to set it, which is #24's
* sentence broken in the band it was written for.
*/
test('reaches the playlist detail view too', async ({ app }) => {
await app.getByTestId('tab-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
// `.playlist-item`, which is what the list renders. Asserted to
// exist rather than skipped on: the seed has a playlist, and a
// spec that quietly skips when its selector stops matching is a
// spec that reports success for a renamed class.
const first = app.locator('playlist-view .playlist-item').first();
await expect(first).toBeVisible();
await first.dblclick();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlist-details',
);
await expect(
app.locator('playlist-details search-trigger button'),
).toBeVisible();
});
/**
* A button that cannot do anything is worse than none the rule
* `library-status-indicator` was rewritten on. Home has nothing of
* its own to search and is not in the store's map.
*/
test('offers no button where there is nothing to search', async ({ app }) => {
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'home',
);
await expect(
app.locator('home-view page-header search-trigger button'),
).toHaveCount(0);
});
test('offers no button on a desktop, where the header has a box', async ({
app,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveCount(0);
await expect(app.locator('header.top-bar search-bar')).toBeVisible();
});
});
/**
* #148, which #57 inherits: `library-filter` is the only control in the
* app that calls `setSelectedLibrary`, and the bar it lived in is gone
* on a phone. #143 refused to hide it as a fit step for exactly this
* reason, so dropping it here would have been the same trade.
*/
test.describe('the library filter has a home that is not the bar', () => {
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is in Settings, and is reachable from a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-more').click();
await app.getByTestId('nav-drawer').getByTestId('nav-settings').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'settings',
);
const filter = app.getByTestId('settings-library-filter');
await expect(filter).toBeVisible();
await expect(filter.locator('select')).toBeVisible();
});
test('and it is the same control at every width', async ({ app }) => {
// Not a phone-only copy: "where do I change which library I am
// browsing" having two answers by viewport is the fault, not the
// fix.
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-settings').click();
await expect(app.getByTestId('settings-library-filter')).toBeVisible();
await expect(app.locator('header.top-bar library-filter')).toBeVisible();
});
});
+71
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);
@@ -130,6 +182,25 @@ test.describe('the shell on a phone', () => {
// are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
// Volume is here **because the player says there is one** (#64),
// not because this is a phone. This tier is the platform that owns
// its own volume, so what it can assert is that the control's
// presence follows that answer -- an inverted polarity in
// `volume-style-store` fails here and in `bottom-bar.spec.ts`, and
// the *absent* branch is checked in the component tier, where the
// binding can be stubbed. Nothing here can reach the Android side.
const systemOwns = await app.evaluate(
async () =>
(await window.__yjEvents.call(
'player.Player.SystemOwnsVolume',
[],
5_000,
)) as boolean,
);
expect(systemOwns, 'this platform should own its own volume').toBe(false);
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
+315
View File
@@ -0,0 +1,315 @@
import { test, expect } from '../support/fixtures.js';
/**
* The phone's transport (#59, #56).
*
* #56 reports that "the playback controls are the most important thing
* in the mobile app and they are tiny". Measured at the reference
* device's 424x439 before this, every one of them was **33x21px**, and
* the favourite beside them which #59 keeps on the bar was
* **18x14px**, the smallest control in the app.
*
* #59 is what makes the sizes affordable: five controls plus a queue
* button at 44px does not fit 424 CSS px, so the bar carries three and
* the rest are on the full-screen view.
*
* **The assertion that matters is not the pixel count.** Plan 018's
* matrix promises that *no action is ever unreachable at any supported
* size*, and #59 removes three controls from the phone's bar so the
* first thing this file checks is that all three are still reachable,
* by walking the route a user would. A spec that only measured the
* survivors would be green on a build that had made shuffle
* unreachable, which is the failure mode this pair of issues is one
* mistake away from.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
const DESKTOP = { width: 1280, height: 800 };
/**
* The touch-target floor. 44px is what #56's Findings name and what
* #55's queue header was sized to, so the app has one number.
*/
const TARGET = 44;
/** The play button is named for its action, not its identity. */
const PLAY_PAUSE = /^(Play|Pause)$/;
const barControls = (page: Page) =>
page.locator('audio-player player-controls');
/**
* `name` may be a regex, and for play/pause it must be: that button is
* named for the *action*, so it is "Pause" while a track runs and
* "Play" when it stops. An exact 'Play' made these tests wait out a
* fixture track (11.1s each, passing by luck) and would have failed
* outright against `LONG_TRACK`. A test about a control's size does not
* care what the transport is doing.
*/
async function sizeOf(
page: Page,
name: string | RegExp,
): Promise<[number, number]> {
const box = await page
.getByRole('button', { name, exact: typeof name === 'string' })
.boundingBox();
expect(box, `no button named ${name}`).not.toBeNull();
return [box!.width, box!.height];
}
/** Put something in the queue, so the transport has a track to act on. */
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,
);
});
}
test.describe('the phone bar carries three controls', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await stageATrack(app);
});
test('drops shuffle, repeat and the queue from the bar', async ({ app }) => {
const bar = barControls(app);
await expect(bar.getByRole('button', { name: 'Previous track' })).toBeVisible();
await expect(bar.getByRole('button', { name: 'Next track' })).toBeVisible();
// Not in the bar's own subtree. Asserted against the bar rather
// than the page, because the whole point is that they moved rather
// than went away -- a page-wide `not.toBeVisible()` would fail the
// moment Now Playing is open and would be asserting the wrong
// thing besides.
await expect(bar.getByRole('button', { name: 'Shuffle' })).toHaveCount(0);
await expect(bar.getByRole('button', { name: /^Repeat/ })).toHaveCount(0);
await expect(app.locator('#queue-button')).toBeHidden();
});
/**
* The promise, walked. Every control #59 takes off the bar is
* reachable from the mini player's art in one tap.
*/
test('leaves every removed control reachable from Now Playing', async ({
app,
}) => {
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
await expect(app.getByRole('button', { name: 'Shuffle' })).toBeVisible();
await expect(app.getByRole('button', { name: /^Repeat/ })).toBeVisible();
await expect(app.getByRole('button', { name: 'Show the queue' })).toBeVisible();
});
test('sizes what is left for a thumb', async ({ app }) => {
for (const name of ['Previous track', 'Next track']) {
const [w, h] = await sizeOf(app, name);
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
}
// Play is deliberately bigger than its neighbours: a row of
// identical squares says every action is equally likely, which is
// not true of play.
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
const [nw] = await sizeOf(app, 'Next track');
expect(ph).toBeGreaterThanOrEqual(TARGET);
expect(pw).toBeGreaterThan(nw);
});
/**
* The favourite was 18x14 and is one of the three controls #59
* keeps, so it is part of this issue rather than a nicety.
*/
test('sizes the favourite, which was the smallest control in the app', async ({
app,
}) => {
const fav = app
.locator('now-playing')
.getByRole('button', { name: /Favorites$/ });
const box = await fav.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(TARGET);
expect(box!.height).toBeGreaterThanOrEqual(TARGET);
});
/**
* **The route to the queue must not depend on what is playing.**
*
* `now-playing` renders two branches, and the no-track one had no
* `.expand` button on its placeholder so with nothing loaded there
* was no way to Now Playing, and once #59 takes the queue button off
* the bar that makes the *queue* unreachable. The queue is persisted
* across restarts, so "tracks queued, nothing playing" is a state the
* app launches into.
*
* This is asserted with the queue explicitly emptied rather than by
* relying on the app not having played anything: `make e2e` runs one
* long-lived app across every spec file (#168), so "no track loaded"
* is otherwise whatever the file before this one left behind which
* is how the underlying fault first showed up as a flake in a spec
* about something else.
*/
test('reaches the queue with nothing playing', async ({ app }) => {
await app.evaluate(async () => {
await window.__yjEvents.call('queue.Queue.Clear', [], 10_000);
});
await expect(app.getByTestId('open-now-playing')).toBeVisible();
await app.getByTestId('open-now-playing').click();
await app.getByTestId('npv-queue').click();
await expect(app.locator('#queue-panel')).toHaveAttribute('open', '');
});
test('still fits, with nothing to scroll sideways to', async ({ app }) => {
const fit = await app.evaluate(() => ({
scroll: document.body.scrollWidth,
client: document.body.clientWidth,
}));
expect(fit.scroll).toBe(fit.client);
});
});
test.describe('the full-screen transport is the page', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await stageATrack(app);
await app.getByTestId('open-now-playing').click();
});
test('draws all five, larger than the bar draws any', async ({ app }) => {
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
expect(pw).toBeGreaterThanOrEqual(56);
expect(ph).toBeGreaterThanOrEqual(56);
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
const [w, h] = await sizeOf(app, name);
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
}
});
test('fits at both phone widths', async ({ app }) => {
for (const size of [DEVICE, PHONE]) {
await app.setViewportSize(size);
const fit = await app.evaluate(() => ({
scroll: document.body.scrollWidth,
client: document.body.clientWidth,
}));
expect(fit.scroll, `${size.width}px`).toBe(fit.client);
}
});
});
/**
* **The desktop bar is not what either issue is about, and must not
* move.** Both are `Platform/Android`; this is the guard that says so
* in a way a build can check.
*
* It caught a real regression while it was being written: a generic
* `font-size` on the buttons took them from the UA stylesheet's 13.3px
* to the shell's 16px and grew every one from 33x21 to 36x24 a
* change nobody asked for, invisible to every other assertion here.
*/
test.describe('the desktop bar is untouched', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
await stageATrack(app);
});
test('keeps all five controls and the queue button', async ({ app }) => {
const bar = barControls(app);
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
await expect(bar.getByRole('button', { name })).toBeVisible();
}
await expect(bar.getByRole('button', { name: /^Repeat/ })).toBeVisible();
await expect(app.locator('#queue-button')).toBeVisible();
});
/**
* **The mechanism, because the pixels are the engine's.**
*
* The first version of this asserted the literal `'33x21'`, measured
* on `main` in Chromium and WebKit draws the same button **36x24**,
* so it failed in CI on a build where nothing was wrong. A button's
* box comes from the UA stylesheet when the author sets nothing, and
* what each UA sets is its own business.
*
* What this PR must not do is *set* anything here, so that is what is
* asserted: our two box properties are unset, and the font is still
* the UA's rather than the shell's. That is precisely the regression
* this caught the first time a generic `font-size: inherit` took
* these from the UA's default to 16px and it catches it in either
* engine.
*/
test('sets no size of its own on the desktop bar', async ({ app }) => {
const measured = await barControls(app).evaluate((el) => {
// A bare button with no author styles: whatever this engine
// gives one is what the bar's buttons must still be.
const probe = document.createElement('button');
document.body.appendChild(probe);
const uaFontSize = getComputedStyle(probe).fontSize;
probe.remove();
return [...el.shadowRoot!.querySelectorAll('button')].map((b) => {
const cs = getComputedStyle(b);
const r = b.getBoundingClientRect();
return {
minWidth: cs.minWidth,
minHeight: cs.minHeight,
usesUaFont: cs.fontSize === uaFontSize,
size: `${Math.round(r.width)}x${Math.round(r.height)}`,
};
});
});
expect(measured).toHaveLength(5);
for (const m of measured) {
expect(m.minWidth, 'min-width').toBe('0px');
expect(m.minHeight, 'min-height').toBe('0px');
expect(m.usesUaFont, 'font-size is still the UA default').toBe(true);
}
// And all five are the same box: `.play` takes a larger size in
// both sized contexts, so this is what says the desktop is neither
// of them.
expect(new Set(measured.map((m) => m.size)).size).toBe(1);
});
});
+373
View File
@@ -0,0 +1,373 @@
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #55 the queue is a *place* while it covers the content, and a
* *control* while it sits beside it.
*
* #24 already made the pixels right: measured at the reference device's
* 424×439, the overlaid panel is 424×318, which is `.main-panel`'s rect
* exactly. What was missing was the navigation model, and the defect was
* measurable in one line opening the queue on Artists and pressing
* back moved the page *underneath* to Albums and left the queue up. A
* back press that changes something the user cannot see, and costs them
* their place, is the whole of "it does not flow".
*
* **These assert the entry, not the attribute.** The temptation is to
* check `#queue-button[aria-expanded]` and stop, which is the shell's
* own bookkeeping and was right throughout the bug: what has to be true
* is that *one* back press closes the queue and the *next* one
* navigates. Asserting only the first would pass on a build that
* orphans the entry, which is the defect moved one press later the
* same trap `back-navigation.spec.ts` documents about `data-active-view`
* and `layout-overflow.spec.ts` set for #69.
*
* **Three of these nine fail on the build before #55**, and the other
* six cannot, which is worth knowing before trusting them: "the entry
* is not orphaned" and "the column is not in the stack" are both
* vacuously true of a build that pushes no entry at all, and the
* containment assertion pins the mount that was *not* taken. They guard
* the next change rather than reproducing this one the three that
* reproduce it are the two back-press tests and the touch target.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport, not a resized desktop. */
const DEVICE = { width: 424, height: 439 };
/** Wide enough that the queue is a column: 1280 200 320 ≥ 480. */
const DESKTOP = { width: 1280, height: 800 };
/**
* The Compact band, where the queue is a *screen* (644 320 < 480) and
* the bottom bar still carries its button.
*
* Two of these tests need both facts at once and only this band has
* them: below 600px #59 takes the button off the bar, so there is no
* toggle to re-press and the queue is opened from Now Playing which
* is itself a detail view, so "the destination stays lit" is vacuously
* true there rather than tested.
*/
const COMPACT = { width: 700, height: 600 };
const activeView = (page: Page) => page.getByTestId('main-content');
const queue = (page: Page) => page.locator('#queue-panel');
const toggle = (page: Page) => page.locator('#queue-button');
/**
* Whether the queue is up.
*
* The panel's own attribute rather than the toggle's `aria-expanded`,
* because below 600px there is no toggle to ask (#59) and the panel
* is the one fact both of them reflect anyway.
*/
async function expectQueue(page: Page, open: boolean): Promise<void> {
const panel = queue(page);
if (open) {
await expect(panel).toHaveAttribute('open', '');
} else {
await expect(panel).not.toHaveAttribute('open', '');
}
}
test.describe('the queue is a screen where it covers the content', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
// On a phone the queue is opened from Now Playing (#59), so the page
// *underneath* it is `now-playing` and the journey is two entries
// deep: albums -> now-playing -> queue. That is the real route a user
// takes, which is why these do not reach for the shortcut.
test('back closes the queue and leaves the page where it was', async ({
app,
}) => {
await expect(queue(app)).toHaveAttribute('overlay', '');
await openTheQueue(app);
await expectQueue(app, true);
await app.goBack();
await expectQueue(app, false);
// The page underneath is untouched. Before #55 this was the
// *previous* view, because the queue was not in the stack at all
// and back spent an entry navigating something nobody could see.
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'now-playing',
);
});
test('costs exactly one entry, so the next press navigates', async ({
app,
}) => {
await openTheQueue(app);
await expectQueue(app, true);
await app.goBack();
await expectQueue(app, false);
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'now-playing',
);
await app.goBack();
// Exactly one entry each: the second press leaves Now Playing for
// the page it was opened from, rather than being swallowed by a
// queue that had already closed.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
/**
* Every route out unwinds the entry, and they do it through the
* panel's own `open` attribute rather than each knowing about
* history which is why a fourth route added later gets this free.
*
* The failure this pins is silent: close by button, and if the entry
* is orphaned the app looks correct until the next back press does
* nothing at all. It is a guard rather than a reproduction a build
* with no entry to orphan passes it and it is paired with the two
* above, which do reproduce.
*/
for (const [name, dismiss] of [
[
'the close button',
async (app: Page) => {
await app.getByRole('button', { name: 'Close queue' }).click();
},
],
[
'Escape',
async (app: Page) => {
await app.keyboard.press('Escape');
},
],
] as Array<[string, (app: Page) => Promise<void>]>) {
test(`${name} leaves no entry behind`, async ({ app }) => {
await openTheQueue(app);
await expectQueue(app, true);
await dismiss(app);
await expectQueue(app, false);
await app.goBack();
// One press, one screen: Now Playing is what the queue was opened
// from, so leaving it lands on Albums. An orphaned entry would
// have spent this press on nothing and left it here.
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'albums',
);
});
}
/**
* A detail view leaves the destination it was opened from lit
* (`active-view-store`, #72), and the queue inherits that it is
* published with `isPrimary: false`, so `isActive('albums')` is still
* true underneath it.
*
* `aria-current` rather than a class, for the reason
* `back-navigation.spec.ts` gives: the class was right throughout the
* bug that rule exists for.
*/
/**
* 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 }) => {
await openTheQueue(app);
const box = await app
.getByRole('button', { name: 'Close queue' })
.boundingBox();
expect(box).not.toBeNull();
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);
});
});
/**
* **The mechanism, because no tier here can see the consequence.**
*
* #55's Direction asked for a `DETAIL_LOADERS` mount, which would put
* the panel inside `.main-panel > *`. That box is paint-contained under
* a `.main-panel` that is too, and `contain: paint` makes an element a
* containing block for fixed descendants *and clips them* which is
* what a `wa-popup` falls back to on the reference device's Chrome 113,
* where the Popover API does not exist (#60, `.planning/NOTES.md`).
* `queue-panel` has a context menu, so that mount would have broken a
* working menu on the one device this issue is about.
*
* CI's Chromium and WebKit both *have* the Popover API, so the menu is
* top-layered and correct here either way: a spec asserting "the menu is
* not clipped" is green on the broken build. What a browser can answer
* honestly is where the element is, so that is what this asks.
*/
test('the panel stays out of the paint-contained region', async ({ app }) => {
await app.setViewportSize(DEVICE);
// Open, because that is the only state in which a menu can be opened
// from it — and because the host drops `paint` from its own
// containment deliberately in overlay mode, so a closed panel answers
// a different question.
await openTheQueue(app);
await expectQueue(app, true);
const ancestry = await app.evaluate(() => {
const chain: Array<{ tag: string; contain: string }> = [];
for (
let el = document.getElementById('queue-panel');
el && el !== document.documentElement;
el = el.parentElement
) {
chain.push({
tag: el.tagName.toLowerCase(),
contain: getComputedStyle(el).contain,
});
}
return chain;
});
expect(ancestry.length).toBeGreaterThan(1);
expect(ancestry.some((a) => a.tag === 'main')).toBe(false);
for (const { tag, contain } of ancestry) {
expect(
`${tag}: ${contain}`,
'a paint-contained ancestor clips a fixed-positioned popup on Chrome 113',
).not.toMatch(/paint|content|strict/);
}
});
/**
* Two properties need the queue to be a *screen* and the bar to still
* have its button, and only the Compact band has both below 600px #59
* takes the button off the bar.
*/
test.describe('a screen opened from the bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(COMPACT);
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(queue(app)).toHaveAttribute('overlay', '');
});
/**
* A detail view leaves the destination it was opened from lit
* (`active-view-store`, #72), and the queue inherits that it is
* published with `isPrimary: false`, so `isActive('albums')` is still
* true underneath it.
*
* `aria-current` rather than a class, for the reason
* `back-navigation.spec.ts` gives: the class was right throughout the
* bug that rule exists for.
*/
test('leaves the destination it was opened from highlighted', async ({
app,
}) => {
// By testid, not by role: at 700px the sidebar is in icon mode, so
// what the item is *named* is a different question from which item
// it is. The assertion is still `aria-current`, which is the
// accessible fact.
const albums = app.getByTestId('nav-albums');
await expect(albums).toHaveAttribute('aria-current', 'page');
await toggle(app).click();
await expectQueue(app, true);
await expect(albums).toHaveAttribute('aria-current', 'page');
});
/** The toggle is a fourth way out, and it unwinds the entry like the
* other three through the panel's attribute, not its own handler. */
test('closes from the same toggle, leaving no entry behind', async ({
app,
}) => {
await toggle(app).click();
await expectQueue(app, true);
await toggle(app).click();
await expectQueue(app, false);
await app.goBack();
await expect(activeView(app)).not.toHaveAttribute(
'data-active-view',
'albums',
);
});
});
/**
* The column is not a place. Somebody docked it; back must not undock
* it, and navigating to another view must not take it away.
*
* This is the half a viewport breakpoint would get wrong: the mode is
* computed from the panel's own drag-resizable width, so the queue
* becomes a screen exactly when it stops being affordable as a column.
*/
test.describe('a docked queue is not in the back stack', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('survives a navigation, and back navigates the page', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await toggle(app).click();
await expectQueue(app, true);
await expect(queue(app)).not.toHaveAttribute('overlay', '');
await app.getByTestId('nav-artists').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'artists');
await expectQueue(app, true);
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectQueue(app, true);
});
});
+10 -10
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js';
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #24 the queue panel does not take the page's width away from it.
@@ -54,15 +54,15 @@ const shellGeometry = (page: import('@playwright/test').Page) =>
};
});
async function openQueue(page: import('@playwright/test').Page) {
const toggle = page.locator('#queue-button');
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
/**
* Opening the queue is `openTheQueue`, which takes the route this
* viewport offers. It used to be a local helper that clicked
* `#queue-button` unconditionally, and #59 hid that button below
* 600px -- so the two phone bands here failed on a build where the
* queue was working perfectly, having been asserting *how* it opens as
* much as what it does.
*/
const openQueue = openTheQueue;
test.describe('an open queue leaves the content its width', () => {
for (const band of BANDS) {
+11 -6
View File
@@ -103,12 +103,17 @@ async function queueSixAndOpen(app: Page): Promise<void> {
*
* `explore-link` routes a track name to its *album's* page, so a
* track with no album renders a name that navigates nowhere and
* the fixture library deliberately contains two (`01 Tone A`,
* `02 Tone B`). Which tracks arrive first is `audio_files.id`
* order, i.e. the order the **scan** inserted them, which depends
* on concurrency and directory traversal: locally the first eight
* all had albums and the spec passed twice over, and CI rebuilds
* its seed with a real scan and got a different eight.
* the fixture library deliberately contains two,
* `unsorted/no-tags-at-all.mp3` and `unsorted/title-only.mp3`.
* (It contained four until #104: the two WAVs under `Field
* Recordings/Test Tones` had been tagged on disk all along and
* scan in with their album now, so they are ordinary tracks and
* not examples of this.) Which tracks arrive first is
* `audio_files.id` order, i.e. the order the **scan** inserted
* them, which depends on concurrency and directory traversal:
* locally the first eight all had albums and the spec passed twice
* over, and CI rebuilds its seed with a real scan and got a
* different eight.
*
* Asking for what the test needs is the fix. It is not a
* narrowing: every assertion here wants an ordinary track, and
+65 -3
View File
@@ -26,10 +26,22 @@ type Page = import('@playwright/test').Page;
* 600 is the bottom of the Compact band (#24) and where the defect
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
* find, at the width that just gained the sidebar's labels); 800 is the
* enforced minimum; 390 is a phone, where the answer must be that
* nothing collapses because the media queries already did the work.
* enforced minimum.
*
* **390 is kept, and what it asks changed with #57.** There is no bar
* to fit below 600px any more it is out of the grid and visually
* hidden so "nothing hangs out of it" is a claim about an element
* with no row, and would pass on a build that had merely broken the
* bar. Dropping the width would be dropping the one place this file
* can still say something true about a phone, so it asserts the
* *stronger* property instead, below: the bar is out of the layout
* altogether, which is the thing #57 wanted and the thing that makes
* fitting moot.
*/
const WIDTHS = [390, 600, 800, 899, 900, 1440];
const WIDTHS = [600, 800, 899, 900, 1440];
/** Where #57 leaves the bar, and where the desktop still has one. */
const PHONE_WIDTH = 390;
/**
* A scan whose title is as long as a real one gets. The label is capped
@@ -90,6 +102,56 @@ const collapsed = (page: Page) =>
}));
test.describe('the top bar fits the window', () => {
/**
* The phone's answer, which is not "it fits" (#57).
*
* The bar has no grid row below 600px, so measuring its children
* against its content box is measuring a 1px box that is already
* invisible a fit pass would collapse the wordmark every time and
* report success about nothing, which is why `measureTopBarFit`
* declines to run at all when the bar is out of flow. What is worth
* asserting here is that the fit pass has not quietly started
* *undoing* that: a rule that put the bar back in the layout would
* pass every assertion in this file and cost a 439px screen 12% of
* its height.
*/
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// Not merely hidden: `display: none` on the header would satisfy
// "invisible" and leave the 3.25em row exactly where it was. So
// the assertion is that the content starts where the row above it
// ends -- and with a job staged, the row above it is the jobs
// band, which is the whole reason this row could go.
await expect
.poll(() =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band')!;
return {
position: getComputedStyle(bar).position,
gap:
Math.round(main.getBoundingClientRect().top) -
Math.round(band.getBoundingClientRect().bottom),
};
}),
)
.toEqual({ position: 'absolute', gap: 0 });
// And the work is still visible, in the band that replaced the
// indicator (#62) — which is what made this row removable at all.
await expect(app.locator('job-indicator')).toBeHidden();
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
await app.setViewportSize({ width: 1440, height: 900 });
});
for (const width of WIDTHS) {
test(`no control sits outside the bar at ${width}px, idle`, async ({
app,
+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);
});
});
+44
View File
@@ -140,6 +140,50 @@ export async function navigateTo(page: Page, view: string): Promise<void> {
.waitFor({ state: 'attached' });
}
/**
* Open the queue the way a user at this viewport would.
*
* **The route differs by width and that is the feature, not an
* inconvenience.** Above 600px the bottom bar carries a queue button.
* Below it that button is gone (#59) and the queue is reached from the
* full-screen Now Playing view, which the mini player's art opens
* "reachable only from Now Playing", which is what the issue asks for.
*
* It is here rather than in one spec because four files need it, and
* because a spec that hard-codes `#queue-button` is quietly asserting
* *which* route exists as well as what the queue does. Four of them
* were, which is how hiding one button failed ten tests about
* something else.
*
* The width is read from the page rather than passed, so a caller that
* resizes and then opens does not have to say so twice.
*/
export async function openTheQueue(page: Page): Promise<void> {
const toggle = page.locator('#queue-button');
if (await toggle.isVisible()) {
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
return;
}
// The phone: through Now Playing. `open-now-playing` is the mini
// player's art, which is a button only below 600px.
if (
(await page.getByTestId('main-content').getAttribute('data-active-view')) !==
'now-playing'
) {
await page.getByTestId('open-now-playing').click();
}
await page.getByTestId('npv-queue').click();
await expect(page.locator('#queue-panel')).toHaveAttribute('open', '');
}
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
export class TestCtl {
constructor(private readonly baseURL: string) {}
@@ -142,6 +142,18 @@ export function SetVolume(desiredVolume: $models.UserVolume): $CancellablePromis
return $Call.ByID(1375836663, desiredVolume);
}
/**
* SystemOwnsVolume reports whether the platform's own control is the
* only volume control there is, so this app neither offers one nor
* remembers a level.
*
* It is bound: the frontend renders no `<volume-control>` when it is
* true, at any width.
*/
export function SystemOwnsVolume(): $CancellablePromise<boolean> {
return $Call.ByID(1027623185);
}
/**
* TrackLengthInSeconds returns the duration of the current track.
*/
+169 -45
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 {
@@ -412,10 +432,21 @@ body div.sidebar {
=================================================================== */
@media (max-width: 599px) {
body {
/* **There is no top-bar row here (#57).** Every one of the five
things that bar held has somewhere else to be below 600px:
`nav-history` is the platform's own gesture (gone from 899
down), the job indicator is `<job-band>` (#62), the search
box is a modal opened from the view's own header
(`search-trigger`), the library filter is Settings ->
Libraries (#148), and the wordmark is below. That is 3.25em
of a 439 CSS px viewport -- the single biggest vertical win
available on the reference device, which is why #57 asks for
the row rather than for a smaller bar. */
grid-template:
"top-bar" 3.25em
"jobs-band" auto
"main-panel" 1fr
"bottom-bar" auto
"progress-line" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
@@ -432,46 +463,55 @@ body div.sidebar {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
/* The bar is out of the layout, and out of it the way the *wordmark*
already goes at desktop widths: visually hidden rather than
`display: none`, because that `h1` is the document's top-level
heading and this app would otherwise have none on the pages whose
own header is empty by design (`page-header` renders no `h1` when
`heading` is '', and Settings has no `page-header` at all).
`min-width: 0` is the load-bearing half. A grid item's implicit
minimum is `auto` -- its content -- so a header whose children
ask for 580px makes the *body* 580px wide inside a 360px
viewport, and `overflow-x: hidden` then hides the right-hand
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */
Its four *controls* are `display: none` below, which is what
keeps them out of the tab order -- a visually-hidden container is
still focusable, and tabbing into a search box nobody can see is
worse than not having one.
This is `styles/sr-only.css.ts`'s recipe again, written out
because that one is a `CSSResult` for shadow roots and this is
the light DOM. `position: absolute` is also what tells
`services/top-bar-fit.ts` there is no row to fit into. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
gap: 0;
min-width: 0;
}
.top-bar nav-history,
.top-bar library-filter,
.top-bar search-bar,
.top-bar job-indicator {
display: none;
}
/* `min-width: 0` is load-bearing wherever a box sits between the
viewport and content that must shrink. A grid item's implicit
minimum is `auto` -- its content -- so one child insisting on
580px makes the *body* 580px wide inside a 360px viewport, and
`overflow-x: hidden` then hides the right-hand third of the app
rather than fitting it. */
.content-area,
.main-panel,
.bottom-bar {
min-width: 0;
}
.title {
font-size: 1.1em;
}
/* The search box is the one header control worth its width; the
library filter is a rarely-changed setting and reachable from
the drawer's Settings.
`nav-history` is already gone from 899 down. It would belong
here anyway and for a stronger reason than width: the phone has
Back as a gesture or a button the OS owns, and this app hooks it
(`popstate`), so a second Back in the chrome duplicates a
control the platform provides. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
@@ -482,14 +522,10 @@ body div.sidebar {
expression of the same fact is a second thing to keep in step.
The view carries its own queue button, because this is where
that one lived. */
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar,
body:has(#main-content[data-active-view="now-playing"]) player-progress-line {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
@@ -508,17 +544,105 @@ body div.sidebar {
}
/* Volume stands down here whatever the setting says, because this
is about room and about the platform rather than about
preference: the hardware keys own volume on a phone, which is
also why mediacontrols' Android handler implements no volume
callback. It moved from `audio-player`'s own media query when
#42 moved the control into the bar same rule, and now stated
where the element actually is.
is about room: five controls and a slider do not fit a 360px
bar, and the full-screen now-playing view is where seeking and
volume go on a phone. It moved from `audio-player`'s own media
query when #42 moved the control into the bar same rule, and
now stated where the element actually is.
`.bottom-bar volume-control`, not the one in
`now-playing-view`: that view is the phone's transport and is
where a slider does belong. */
**This rule used to carry the platform argument too, and no
longer does** (#64). "The hardware keys own the volume" is not a
width: it is false of a narrow desktop window and true of an
Android tablet, which this selector gets backwards both ways.
The player answers it now `SystemOwnsVolume` and
`volume-control` renders nothing when it is true, at every
width and in both of its mount points. What is left here is the
question a stylesheet can actually answer. */
.bottom-bar volume-control {
display: none;
}
/* The queue leaves the phone's bar (#59), because #55 made it a
screen with an entry in the back stack and Now Playing already
carries its own button for it. The route is the mini player's
art -> Now Playing -> the queue, which is the "reachable only
from Now Playing" this issue asks for.
This is allowed to remove a control only because the control is
still reachable: plan 018's matrix promises that no action is
ever unreachable at any supported size, and that promise is what
`phone-transport.spec.ts` asserts rather than the button count.
**`.bottom-bar #queue-button`, not `#queue-button`**, and that is
not decoration. The rule this overrides is written *nested*
inside `.bottom-bar`, so it builds to a descendant selector one
class more specific than it looks in the source -- and a bare
`#queue-button` here loses to it, media query or not. Being last
in the file is not enough when the thing above is more specific,
which is the same lesson as this section's own header one level
down: nesting adds specificity the source does not show, and the
failure is silent (the button simply stayed). */
.bottom-bar #queue-button {
display: none;
}
}
/* Out of the desktop grid entirely. `job-band` renders nothing above
600px anyway, but an in-flow grid child with no named area is
auto-placed into a row of the shell -- the same trap the skip link is
absolutely positioned to avoid. `player-progress-line` (#58) is the
same element in the same position for the same reason: below 600px it
has a named row, and above it there is no border for it to sit on --
the desktop bar carries a real, interactive seek bar. */
body job-band,
body player-progress-line {
display: none;
}
/* #62. The job indicator stands down on the phone, and its work is
shown in the notification band instead (notification-host).
Three reasons, and the first is the report: its popover is anchored
to the top bar, which is 3.25em here on a viewport 439 CSS px tall,
and it was reported as unreadable behind other UI. The second is
that a popover is a disclosure, and background work is the one thing
a phone should not make you disclose. The third is #57, which
deletes this bar entirely and is blocked on the indicator having
somewhere else to live -- this is that somewhere.
#57 has since done exactly that, so the indicator's own rule now
lives with the other three in the phone block above, where the bar
goes out of the layout in one statement rather than four. What stays
here is the band, and the argument for it. */
@media (max-width: 599px) {
/* The indicator's rows appear here, in the grid row above the content.
In flow rather than over it: a fixed band reads fine in a
screenshot and is unusable, because at 424x439 a compact panel
is ~200px of a 439px screen and it *covers* what is under it.
Measured, not assumed -- four e2e specs failed on that version,
two phone-shell journeys and the header's action menu, because
the panel was intercepting the taps. */
body job-band {
display: block;
grid-area: jobs-band;
background-color: var(--yj-bg-elevated, #343a40);
}
}
/* #58. How far through the song we are, in its own grid row between
the two bars -- so the line is *on* the border rather than inside
either of them, and in flow rather than over it. The row is `auto`
and the element renders nothing while no track is loaded, so it costs
no height at all until there is something to say.
**This block is below the `display: none` above and has to be**, for
the reason the band's rule is: a media query adds no specificity, so
`body player-progress-line { display: block }` written before that
rule loses to it at equal specificity and the line never appears at
any width. Nothing fails; it is simply not there. */
@media (max-width: 599px) {
body player-progress-line {
display: block;
grid-area: progress-line;
}
}
+31
View File
@@ -14,6 +14,13 @@
user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a>
<!-- Below 600px this bar is not in the layout at all (#57): index.css
takes its grid row away and leaves the element visually hidden,
carrying nothing but the `h1` below. Every control in it has
somewhere else to be there -- `nav-history` is the platform's
own back gesture, `job-indicator` is `<job-band>`, `search-bar`
is `<search-dialog>` opened from the view's own header, and
`library-filter` is Settings -> Libraries (#148). -->
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
@@ -30,6 +37,14 @@
<search-bar></search-bar>
<job-indicator></job-indicator>
</header>
<!-- The phone's view of background work (#62): below 600px the
indicator above stands down and its rows appear here instead,
in the layout rather than over it. `display: none` above that
width in index.css, which is also what keeps it out of the
desktop grid -- an in-flow child with no named area is
auto-placed into one of the shell's rows, which is the trap the
skip link is absolutely positioned to avoid. -->
<job-band></job-band>
<div class="sidebar">
<app-sidebar></app-sidebar>
</div>
@@ -66,6 +81,16 @@
</button>
</div>
</footer>
<!-- How far through the song we are, on the border between the two
bars (#58). The shell's element rather than either bar's:
they are separate components stacked in this grid, so a line
on the border between them is a row of it, and neither one has
to reach into the other's box for two pixels. It renders
nothing above 600px and nothing with no track, is `aria-hidden`
(Now Playing's seek bar is what announces the position) and
takes no pointer events at all -- a thin line that sometimes
seeks is worse than one that never does. -->
<player-progress-line></player-progress-line>
<!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason
notification-host is: it is the only way to move around the
@@ -76,6 +101,12 @@
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
<!-- The phone's search surface (#57). A singleton here for the
reason shortcuts-overlay is one: one instance, one document
listener, and no `data-testid="search-input"` resolving to two
elements. It renders nothing while shut, so the header's box
is still the only one on a desktop. -->
<search-dialog></search-dialog>
</body>
</html>
+111 -11
View File
@@ -21,6 +21,10 @@ import '@components/audio-player/audio-player.ts';
// In the bar rather than inside `audio-player` since #42, so the shell
// is what has to register it.
import '@components/audio-player/volume-control/volume-control.ts';
// The phone's progress line (#58), on the border between the mini
// player and the tab bar. In the shell for the same reason the volume
// is, and eager because it is part of the bottom bar's first paint.
import '@components/audio-player/progress-line/progress-line.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
@@ -28,6 +32,11 @@ import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.ts';
// The phone's search surface (#57). Eager, because below 600px it is
// the *only* way to search and a modal that has to fetch a chunk before
// it can take a keystroke is late by exactly the interval it exists to
// remove. It renders nothing until asked.
import '@components/search-dialog/search-dialog.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
@@ -38,6 +47,11 @@ import '@components/confirm-dialog/confirm-dialog.ts';
// not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.ts';
// The phone's half of the same thing (#62). Eager because it is part
// of the shell's first paint below 600px, and because a band that has
// to fetch a chunk before it can say the app is busy is late by
// exactly the interval it exists to explain.
import '@components/jobs/job-band.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -56,7 +70,8 @@ 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 {
hasTrackPayload,
@@ -72,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
@@ -326,6 +344,33 @@ window.addEventListener('popstate', (e: PopStateEvent) => {
void handleNavigate({ ...nav, _isBack: true });
});
/**
* The queue, while it is a screen (#55).
*
* It is *not* in `VIEW_TAGS` and *not* in `DETAIL_LOADERS`: there is
* nothing to mount, because the panel is already in the document and,
* as an overlay, already occupies `.main-panel`'s rect exactly. What a
* navigation adds is the two things that make a screen a screen a
* history entry, so the platform's back gesture answers it, and a
* destination to leave, so navigating anywhere else takes it away.
*
* Keeping it out of both tables is what keeps its context menu working
* on the reference device: `.main-panel > *` is paint-contained and a
* `wa-popup` falls back to `position: fixed` on Chrome 113, which
* escapes overflow but not containment (#60). The panel stays in
* `.content-area`, which is not paint-contained, exactly as it is
* today.
*/
const QUEUE_VIEW = 'queue';
/** Close a queue that is being navigated away from. A *column* is not
* a place, so it survives a navigation the way the sidebar does. */
function dismissQueueScreen(): void {
const panel = queuePanelElement();
if (panel?.hasAttribute('overlay')) panel.removeAttribute('open');
}
async function handleNavigate(
detail: { view: string; [key: string]: any },
): Promise<void> {
@@ -337,6 +382,25 @@ async function handleNavigate(
if (!detail._isBack) recordNavigation(detail);
if (view === QUEUE_VIEW) {
// The shell says where the user is; `false` because the queue is
// not a primary view, so nothing in either nav lights while it
// is up -- the same rule a detail view gets, and the reason the
// tab the queue was opened from stays lit.
activeViewStore.setView(view, false);
queuePanelElement()?.setAttribute('open', '');
// Deliberately not `searchStore.setCurrentView` and not
// `dataset.activeView`: both describe what is *in the main
// panel*, and the queue covers that panel without replacing it.
// Overwriting either would disable the search box belonging to
// the page underneath and make every `data-active-view`
// selector in the suite disagree with the element it names.
return;
}
dismissQueueScreen();
// Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e
@@ -621,13 +685,17 @@ const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
if (queueButton && queuePanel) {
queueButton.addEventListener('click', () => {
const isOpen = queuePanel.hasAttribute('open');
if (isOpen) {
if (queuePanel.hasAttribute('open')) {
// Closing goes through the panel either way; where the queue
// is a screen the observer below is what unwinds its history
// entry, so this button, Escape, the scrim and the close
// button all take the same route out.
queuePanel.removeAttribute('open');
} else {
queuePanel.setAttribute('open', '');
return;
}
openQueue();
});
// The button says whether the panel is open, and it learns that
@@ -645,7 +713,39 @@ if (queueButton && queuePanel) {
);
};
new MutationObserver(reflectQueueState).observe(queuePanel, {
/**
* Keep the back stack honest about a queue that closed itself.
*
* Where the queue is a screen its `open` attribute and the current
* history entry are two statements of one fact, and the panel can
* change its half on its own -- Escape, the scrim, the close button,
* and anything added later. Reconciling here rather than at each of
* those is the same reason this observer already exists for
* `aria-expanded`: the attribute is the one fact, and a state kept
* beside a click is right until something else changes it.
*
* Without this the entry is orphaned and the *next* back press is
* the one that closes the queue -- a press that appears to do
* nothing, which is the defect this issue is about, moved one press
* later.
*
* `history.back()` rather than a stack of our own, for the reason
* `navigate-back` does: two stacks is how a component's own way out
* and the phone's gesture come to disagree about what one press
* means.
*/
const reconcileQueueHistory = () => {
if (queuePanel.hasAttribute('open')) return;
const state = history.state as NavState | null;
if (state?.yjNav?.view === QUEUE_VIEW) history.back();
};
new MutationObserver(() => {
reflectQueueState();
reconcileQueueHistory();
}).observe(queuePanel, {
attributes: true,
attributeFilter: ['open'],
});
+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';
@@ -26,14 +27,16 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
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';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@components/playlist-picker/playlist-picker.js';
import { dict, list } from '@utils/binding';
@@ -131,17 +134,17 @@ export class ArtistsView
private contextMenuArtistId: number | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| WaPopup
| MenuTarget
| undefined {
return this.playlistSubmenuPopup;
}
@@ -581,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.
*/
@@ -1144,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 = '';
@@ -1171,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"
@@ -1336,11 +1374,8 @@ export class ArtistsView
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1434,13 +1469,12 @@ export class ArtistsView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1468,7 +1502,7 @@ export class ArtistsView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -1534,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()}
@@ -1,22 +1,77 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* The transport, in the two places it appears.
*
* **The context is a property and cannot be a media query**, which is
* the whole reason this exists (#56). Everywhere else in this app a
* component states what it drops at phone width itself, because a media
* query inside a shadow root is answered by the viewport and that is
* the honest signal. Here the two hosts want *different* answers at the
* *same* viewport: on a phone the bottom bar wants three controls sized
* for a thumb, and `now-playing-view` wants five, larger still. So the
* host says which context and the viewport says which size band, and
* neither one alone can express it.
*
* Measured at the reference device's 424x439 before this: every button
* here was **33x21px**, in both places, which is what #56 reports as
* "the most important thing in the mobile app and they are tiny".
*/
export type ControlsContext = 'bar' | 'full';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private unsubscribeQueue?: () => void;
/**
* Where these controls are drawn. `bar` is the bottom bar in both
* bands; `full` is the full-screen transport.
*
* Reflected so a spec can read it and so the stylesheet keys off one
* fact rather than a class the host has to remember to set.
*/
@property({ type: String, reflect: true })
context: ControlsContext = 'bar';
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
/**
* Phone width, from `matchMedia` rather than from a media query,
* because what it decides is whether shuffle and repeat *exist* here
* and a stylesheet can only decide whether they are painted.
* `job-band` and `search-trigger` are the same pattern for the same
* reason.
*/
@state() private phone = false;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
/** Whether this is the phone's bottom bar, which carries three
* controls rather than five. */
private get slim(): boolean {
return this.context === 'bar' && this.phone;
}
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
@@ -37,6 +92,7 @@ export class PlayerControls extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
this.media?.removeEventListener('change', this.onMedia);
}
static override styles = [designTokens, css`
@@ -58,6 +114,99 @@ export class PlayerControls extends LitElement {
justify-content: center;
}
/* ---------------------------------------------------------------
Sizes (#56).
44px is the floor everything here is sized to, and play/pause
alone goes above it -- "large play/pause, adequate prev/next" is
the Direction, and it is the one control the report calls "front
and centre".
They are stated as custom properties rather than on each button
so a context sets two numbers instead of five rules, and so the
icon scales with its target: a 44px box around a 16px glyph is a
big hit area that still looks tiny, which is half of what the
report is about.
**The desktop bar sets none of them and must not change at all.**
#56 is an Android issue; the desktop's buttons are 33x21 before
this and are 33x21 after it.
That is why the box rules take a zero fallback and the *font-size*
rules are scoped to the two contexts instead of sharing them. A
button does not inherit its font from its parent -- the UA
stylesheet gives it one -- so a generic font-size: inherit is
not the no-op it reads as: it moved the desktop's buttons from
33x21 to 36x24, silently, by taking them from the UA's 13.3px to
the shell's 16px. Measured before and after by stashing this
file, which is the only way that particular 3px shows up.
--------------------------------------------------------------- */
button {
min-width: var(--yj-control-target, 0);
min-height: var(--yj-control-target, 0);
}
button.play {
min-width: var(--yj-control-play-target, 0);
min-height: var(--yj-control-play-target, 0);
}
/* The phone's bottom bar: three controls, sized for a thumb.
Shuffle and repeat are not here -- see the render method, which
does not draw them rather than hiding them, because a control
that is display:none is still a thing the component claims to
have. They are on the full-screen view, which is one tap away
through the mini player's art (#59). */
@media (max-width: 599px) {
:host([context='bar']) {
--yj-control-target: 44px;
--yj-control-icon: 18px;
--yj-control-play-target: 56px;
--yj-control-play-icon: 24px;
}
:host([context='bar']) button {
font-size: var(--yj-control-icon);
}
:host([context='bar']) button.play {
font-size: var(--yj-control-play-icon);
}
}
/* The full-screen transport, at every width: this view *is* the
player, so the controls are the page rather than a strip of it. */
:host([context='full']) {
--yj-control-target: 44px;
--yj-control-icon: 20px;
--yj-control-play-target: 64px;
--yj-control-play-icon: 28px;
}
:host([context='full']) button {
font-size: var(--yj-control-icon);
}
:host([context='full']) button.play {
font-size: var(--yj-control-play-icon);
}
:host([context='full']) #player-control-buttons {
gap: 12px;
}
/* Secondary controls sit below the primary row rather than beside
it, which is the Direction's shape and is why this is a second
group in the DOM instead of a CSS order property: visual order
and focus order have to agree. */
.secondary {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 8px;
}
button:hover {
color: var(--yj-accent-text, #ffd43b);
}
@@ -104,46 +253,107 @@ export class PlayerControls extends LitElement {
queueStore.cycleRepeat();
};
override render() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
/** Shuffle. Secondary: it changes how the queue behaves rather than
* what is playing now. */
private renderShuffle() {
return html`
<button
class=${this.shuffleMode ? 'active' : ''}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
`;
}
const shuffleClass = this.shuffleMode ? 'active' : '';
/** Repeat, whose label spells the mode out because one icon covers
* three states. */
private renderRepeat() {
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' ');
return html`
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
`;
}
/** Previous, play/pause, next the three that are always drawn, in
* every context and at every width. Only play/pause takes the large
* size: the Direction asks for "large play/pause, adequate
* prev/next", and a row of identical squares says every action here
* is equally likely, which is not true of play. */
private renderPrimary() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
return html`
<button
aria-label="Previous track"
@click=${this.handlePreviousClick}
>
<wa-icon name="backward-step"></wa-icon>
</button>
<button
class="play"
aria-label=${this.player.isPlaying ? 'Pause' : 'Play'}
@click="${playOrPauseHandler}"
>
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button
aria-label="Next track"
@click=${this.handleNextClick}
>
<wa-icon name="forward-step"></wa-icon>
</button>
`;
}
/**
* Two arrangements, not two components.
*
* `bar` keeps the order it has always had shuffle, prev, play,
* next, repeat, one row so nothing about the desktop bar moves.
* `full` puts the primary three on their own row with the secondary
* pair beneath, which the Direction asks for.
*
* **The phone's bar draws three buttons rather than hiding two.** A
* `display: none` control is still in the component's shadow root,
* still in the accessibility tree's markup, and still something a
* `shadowAll('button')[4]` finds so "the phone has three controls"
* would be true of the pixels and false of the element. They are
* reachable on the full-screen view, which the mini player's art
* opens, and through the global shortcuts.
*/
override render() {
if (this.context === 'full') {
return html`
<div id="player-control-buttons">${this.renderPrimary()}</div>
<div class="secondary">
${this.renderShuffle()}${this.renderRepeat()}
</div>
`;
}
return html`
<div id="player-control-buttons">
<button
class=${shuffleClass}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
<button aria-label="Previous track" @click=${this.handlePreviousClick}>
<wa-icon name="backward-step"></wa-icon>
</button>
<button aria-label=${this.player.isPlaying ? 'Pause' : 'Play'} @click="${playOrPauseHandler}">
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button aria-label="Next track" @click=${this.handleNextClick}>
<wa-icon name="forward-step"></wa-icon>
</button>
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
${this.slim ? nothing : this.renderShuffle()}
${this.renderPrimary()}
${this.slim ? nothing : this.renderRepeat()}
</div>
`;
}
@@ -0,0 +1,204 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* How far through the song we are, on the border between the mini
* player and the tab bar (#58).
*
* The phone's bottom bar carries three controls and no seek bar plan
* 016 B2 took it out, because 4px of height is not a thumb target and the
* full-screen `now-playing-view` is where seeking belongs. What went
* with it is the one thing a mini player is expected to say without
* being opened: how far through the song it is. This is that, and
* only that.
*
* Four things about it are load-bearing.
*
* **It is the shell's element, not either bar's.** The mini player and
* `<bottom-nav>` are separate components stacked in the shell's grid,
* so a line on the border between them is a row of the grid either
* one drawing it means reaching into the other's box for two pixels.
*
* **It never counts.** The position is pushed at 1 Hz by the backend
* (`PlaybackPositionChanged`), and the interval here interpolates
* *between* those reports and is stopped and restarted by every one of
* them the seek bar's rule, for the reason the seek bar has it: a
* local clock drifted 30 s away from the backend across four keyboard
* seeks. The `trackChangeId` and `seq` guards come along for the same
* reason: the store is a singleton, so a report about the previous
* track must not be adopted, and the same second reported twice still
* has to reset the interpolation.
*
* **It is not a control and cannot become one.** `aria-hidden` on the
* host and `pointer-events: none` throughout: the real progress is
* announced by the seek bar on Now Playing, and a 2px strip on the top
* edge of the tab bar that sometimes seeks is worse than one that
* never does. It is also where a thumb aiming at a tab lands.
*
* **It renders nothing above 600px**, from `matchMedia` rather than a
* media query, because that decides whether the element *exists* and
* with it whether a 1 Hz interval runs for the life of every desktop
* session about a line nobody can see. `job-band`, `search-trigger`
* and `player-controls` are the same pattern for the same reason.
*/
/**
* The reporting cadence, matched. This is not the clock: it exists
* only so the line moves in the second between two reports, and its
* error is discarded by the next one rather than carried.
*/
const InterpolationIntervalMillis = 1000;
@customElement('player-progress-line')
export class PlayerProgressLine extends LitElement {
private player = new PlayerController(this);
/** Phone width. See the class comment: existence, not paint. */
@state() private phone = false;
/** Seconds into the track, from the last report plus interpolation. */
@state() private elapsed = 0;
private previousTrackChangeId = -1;
/** The sequence number of the last backend report applied. */
private previousPositionSeq = -1;
private timerID = -1;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
static override styles = [
designTokens,
css`
:host {
display: block;
/* Not a target, at any depth. */
pointer-events: none;
}
.track {
height: 2px;
background-color: var(--yj-bg-surface, #212529);
}
.fill {
height: 100%;
background-color: var(--yj-accent, #ffd43b);
/* scaleX off a full-width box rather than a width in
percent, so the moving thing is a transform and the
line costs no layout once a second. */
transform-origin: left center;
}
`,
];
private get trackLength(): number {
return this.player.currentTrack?.trackLength ?? 0;
}
override connectedCallback(): void {
super.connectedCallback();
// Decorative in full: the seek bar on Now Playing is what
// announces the position, and this says the same thing without
// a name, a value or a way to act on it.
this.setAttribute('aria-hidden', 'true');
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.stopInterpolating();
this.media?.removeEventListener('change', this.onMedia);
}
override updated(): void {
// A track change resets the line, and `trackChangeId` is what
// reveals one when the same file plays twice in a row.
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
if (currentChangeId !== this.previousTrackChangeId) {
this.previousTrackChangeId = currentChangeId;
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
this.stopInterpolating();
}
// The backend's own position wins over anything counted here,
// and a report for a track that is no longer loaded is stale by
// definition.
const position = this.player.position;
if (
position &&
position.trackChangeId === currentChangeId &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.elapsed = position.positionSeconds;
this.stopInterpolating();
}
// One owner for the interval, as in `seek-bar`: everything that
// wants it started or stopped says so by changing state that
// brings us back here.
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
this.startInterpolating();
} else {
this.stopInterpolating();
}
}
private stopInterpolating(): void {
if (this.timerID !== -1) {
clearInterval(this.timerID);
this.timerID = -1;
}
}
private startInterpolating(): void {
if (this.timerID !== -1) {
return;
}
this.timerID = window.setInterval(() => {
if (this.elapsed < this.trackLength) {
this.elapsed += 1;
}
}, InterpolationIntervalMillis);
}
override render() {
// Nothing playing is nothing to say, and the grid row is `auto`
// so an empty render costs no height at all -- `job-band`'s
// rule one row down.
if (!this.phone || this.player.currentTrack === null) return nothing;
const length = this.trackLength;
const fraction =
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
return html`
<div class="track" data-testid="progress-line">
<div class="fill" style="transform: scaleX(${fraction})"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'player-progress-line': PlayerProgressLine;
}
}
@@ -22,23 +22,29 @@ export class SeekBar extends LitElement {
@state()
private seekValue: number = 0;
/**
* Whether the user is dragging the thumb right now.
*
* It is `@state` rather than a plain field because `updated()` owns
* the interval and only reactive state brings `updated()` round. A
* bare `stopProgress()` in the input handler mutated nothing, so
* nothing re-rendered, so the tail of `updated()` that restarts the
* interval never ran and the only things that could restart it
* were a `change` event or the next backend report. Any `input`
* without a committed `change` therefore froze the interpolation:
* a drag cancelled outside the element, a pointer taken by a scroll,
* or a touch on the track treated as a scrub, which on a phone are
* ordinary gestures. While playing, the 1 Hz report papered over it
* within a second; with reports not arriving it was permanent.
*/
@state()
private dragging: boolean = false;
/** Whether the right-hand clock shows time remaining or total. */
@state()
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;
@@ -62,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;
@@ -133,6 +190,7 @@ export class SeekBar extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
this.stopProgress();
this.endDrag();
}
override updated() {
@@ -154,18 +212,33 @@ export class SeekBar extends LitElement {
// A report for a track that is no longer loaded is stale by
// definition: the change id is the only thing that distinguishes
// it, since the same file can play twice in a row.
//
// A report arriving mid-drag is deliberately *not* applied: the
// thumb belongs to the finger on it, and adopting a report once a
// second pulls it back out from under them. The seq is left
// unrecorded too, so the first report after the drag still counts
// as fresh.
const position = this.player.position;
const forThisTrack =
position !== null && position.trackChangeId === currentChangeId;
if (position && forThisTrack && position.seq !== this.previousPositionSeq) {
if (
position &&
forThisTrack &&
!this.dragging &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.seekValue = position.positionSeconds;
this.stopProgress();
}
// Start/stop progress interval based on playback state
if (this.isPlaying && this.hasTrack) {
// One owner for the interval, and this is it. Every other place
// that wants it started or stopped says so by changing state that
// brings us back here, so the timer cannot be left running by a
// path that forgot to stop it or stopped by a path that forgot to
// start it again.
if (this.isPlaying && this.hasTrack && !this.dragging) {
this.startProgress();
} else {
this.stopProgress();
@@ -210,18 +283,48 @@ export class SeekBar extends LitElement {
private handleChange(e: Event) {
const newSeekVal = (e.target as WaSlider).value;
this.endDrag();
this.setSeekValue(newSeekVal);
this.player.seek(newSeekVal);
}
if (this.isPlaying) {
this.startProgress();
/**
* The user is moving the thumb.
*
* This only records that fact; `updated()` decides what it means for
* the interval. `seekValue` follows the slider so the clocks track
* the thumb during the drag rather than jumping when it is released.
*/
private handleInput(e: Event) {
this.setSeekValue((e.target as WaSlider).value);
if (this.dragging) {
return;
}
this.dragging = true;
// A drag that never commits must not strand the flag, or this fix
// turns a stall of up to one second into a permanent one -- which
// is the failure it exists to remove. `change` is the ordinary
// end; these are the ones that are not, and they are on the
// document because the pointer is routinely released outside the
// element it started in. A drag's listeners belong to the drag,
// so they go on with it and come off with it.
document.addEventListener('pointerup', this.endDrag);
document.addEventListener('pointercancel', this.endDrag);
document.addEventListener('touchend', this.endDrag);
document.addEventListener('touchcancel', this.endDrag);
}
// Stops progress while user is dragging the thumb
private handleInput() {
this.stopProgress();
}
private endDrag = () => {
document.removeEventListener('pointerup', this.endDrag);
document.removeEventListener('pointercancel', this.endDrag);
document.removeEventListener('touchend', this.endDrag);
document.removeEventListener('touchcancel', this.endDrag);
this.dragging = false;
};
private setSeekValue(val: number) {
if (val < 0) val = 0;
@@ -1,4 +1,4 @@
import { LitElement, html, css } from 'lit';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/slider/slider.js';
@@ -27,6 +27,26 @@ export class VolumeControl extends LitElement {
@state()
private popup = volumeStyleStore.popup;
/**
* Whether there is a volume of ours to control at all (#64).
*
* The decision is made here rather than at either mount point,
* because there are two -- the bottom bar's copy lives in
* `index.html`, which has no module scope to make it conditional --
* and one of them is a control the shell cannot un-render. So the
* control answers for itself, and the bar and the phone's
* full-screen transport get the same answer without either knowing
* the question exists.
*
* It renders `nothing` *and* hides the host: an empty shadow root is
* what stops a positional or role query finding a button that cannot
* act, and `:host([hidden])` is what stops the element occupying a
* flex item's worth of the transport -- the `:host` display above
* outranks the UA's `[hidden]` rule, so it has to be said.
*/
@state()
private available = volumeStyleStore.available;
private unsubscribeStyle?: () => void;
// Locally-tracked volume while the user is actively dragging or scrolling.
@@ -43,6 +63,13 @@ export class VolumeControl extends LitElement {
align-items: center;
}
/* See the available field. A gap is only drawn between boxes,
so a hidden host costs its parent nothing -- which is where the
29px this gives back to Now Playing comes from (#172). */
:host([hidden]) {
display: none;
}
button {
background: none;
border: none;
@@ -154,12 +181,15 @@ export class VolumeControl extends LitElement {
this.unsubscribeStyle = volumeStyleStore.subscribe(() => {
this.popup = volumeStyleStore.popup;
this.setAvailable(volumeStyleStore.available);
// Switching to the slider while the popup is open would leave the
// document listener installed for a popup that no longer renders.
if (!this.popup) this.closeSlider();
});
this.setAvailable(volumeStyleStore.available);
void volumeStyleStore.init();
}
@@ -233,7 +263,25 @@ export class VolumeControl extends LitElement {
// RENDER
// ===================================================================
/**
* `hidden` is set imperatively rather than reflected from the state,
* because it has to be on the *host* and a `@state` does not reflect.
* It is the right attribute besides: it takes the element out of the
* accessibility tree as well as out of the layout.
*/
private setAvailable(available: boolean) {
this.available = available;
this.hidden = !available;
// A popup left open when the control goes away would keep its
// document click listener installed for markup that no longer
// renders.
if (!available) this.closeSlider();
}
override render() {
if (!this.available) return nothing;
const muted = this.player.muted;
// Inline, the icon is the mute toggle rather than a disclosure:
@@ -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;
@@ -56,6 +56,10 @@ import {
import './config-field';
import './config-section';
// The view filter's home (#148). The same component the top bar
// carries, placed a second time rather than reimplemented -- two
// definitions of "which library am I browsing" is what this is for.
import '@components/library-filter/library-filter';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
@@ -172,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 {
@@ -231,6 +242,42 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex-wrap: wrap;
}
/* #148, and the second half of #57.
library-filter is the only control in the app that calls
setSelectedLibrary, and it lived in the top bar -- which
#57 takes out of the layout on a phone, and which #143
already refused to hide as a fit step precisely because
hiding it takes away an action. So the selection gets a home
that does not depend on that bar existing.
At every width, not below 600px: a phone-only copy would be
a second place the control lives, and "where do I change
which library I am browsing" having two answers by size is
the fault, not the fix. */
.library-scope {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
margin-bottom: 1em;
}
.library-scope .scope-label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
display: block;
}
.library-scope .scope-description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0.35em 0 0;
max-width: 40em;
}
.save-row {
display: flex;
gap: 0.5em;
@@ -469,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;
@@ -491,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
@@ -501,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;
@@ -524,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;
@@ -671,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 {
@@ -1963,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"
@@ -1974,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}
@@ -2172,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"
@@ -2182,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"
>
@@ -2370,6 +2463,21 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
for new and changed files."
.open=${true}
>
<div class="library-scope">
<div>
<span class="scope-label">Showing</span>
<p class="scope-description">
Which library the Albums, Artists and Genres
views show. This is a view filter, not a
setting about the libraries themselves the
list below is where they are added, renamed
and scanned.
</p>
</div>
<library-filter data-testid="settings-library-filter">
</library-filter>
</div>
<div class="scan-actions">
<button
class="btn-primary"
@@ -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';
@@ -23,12 +24,14 @@ import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@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';
@@ -51,9 +54,11 @@ import {
ContextMenuController,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
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,
@@ -415,17 +420,17 @@ export class CoverGrid
splitIndex = 0;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// ContextMenuHost interface.
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -907,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
* ==================================================================== */
@@ -2000,6 +2030,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
`;
}
@@ -2034,6 +2065,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
<album-dropdown
@@ -2083,15 +2115,36 @@ 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;
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${ctxMenu.contextMenuOpen}
>
${ctxMenu.contextMenuOpen
@@ -2201,16 +2254,20 @@ export class CoverGrid
</wa-dropdown-item>
`
: nothing}
${goToMenuItems(this.goToTarget, {
onSelect: () => ctxMenu.close(),
onHover: () =>
ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${ctxMenu.playlistSubmenuOpen}
>
${ctxMenu.playlistSubmenuOpen
@@ -2232,7 +2289,7 @@ export class CoverGrid
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
@@ -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 {
@@ -49,9 +50,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -327,7 +330,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private ctxMenuTrack: MBTrack | null = null;
@query('#track-context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -337,11 +340,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -353,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
srOnly,
@@ -377,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;
}
@@ -890,6 +875,67 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
.track-row .track-request {
flex-shrink: 0;
}
/* The phone (#66)
*
* **This block is last on purpose**, for index.css's
* reason: a media query adds no specificity, so a rule
* written above the plain one it overrides loses to it and
* every declaration here is silently dead.
*
* Two faults, one shape. The page is a fixed header over a
* scrolling tracklist the desktop arrangement so at the
* reference device's 424x439 the header owned 253 of the
* panel's 318px and the tracklist scrolled inside the 64px
* that were left. And the header's flex row squeezed
* .album-info to 112px, so the title drew as one ellipsised
* glyph and two of the album's three primary actions were
* clipped by the host's own overflow: Shuffle album ended
* at x=443 in a 424px box, unreachable by any gesture.
*
* .album-info carries min-width: 0 and was shrinking as
* asked, so another one is not the fix the row has to
* stack, or the info column has nothing to be wide with.
*
* The scroller moves to the host and .content stops being
* one, which is what makes the header scroll away; the
* tracklist is plain DOM rather than a virtualizer, so
* nothing inside wants a scroll window of its own. */
@media (max-width: 599px) {
:host {
overflow-y: auto;
}
.album-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* Stacked, the art is the whole of the header's width
* budget and its 200px square is 45% of the reference
* device's height. It is still what identifies the
* album, so it shrinks rather than going. */
.cover-art-container {
width: 140px;
height: 140px;
}
/* A column flex item takes its content's width from
* align-items: flex-start above, which would leave the
* actions wrapping inside a box narrower than the row
* they now have to themselves. */
.album-info {
align-self: stretch;
}
.content {
flex: 0 0 auto;
overflow-y: visible;
padding: 16px 16px 24px;
}
}
`,
];
@@ -3145,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}
@@ -3781,11 +3831,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
const track = this.ctxMenuTrack;
return html`
<wa-popup
<menu-surface
id="track-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && track
@@ -3846,13 +3893,12 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -3869,7 +3915,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -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,
@@ -61,9 +63,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 { dict, dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -215,7 +219,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private ctxMenuTarget: ContextMenuTarget | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -233,11 +237,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -264,6 +268,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [
designTokens,
backButton,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
@@ -287,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;
}
@@ -2621,11 +2607,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const target = this.ctxMenuTarget;
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -2643,13 +2626,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -2666,7 +2648,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -2724,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';
@@ -37,9 +39,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 { dict, dictByName } from '@utils/binding';
import { ICON_QUEUE } from '@utils/icon-language';
@@ -52,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;
@@ -206,13 +228,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
@state() private ctxMenuTarget: ExploreMenuTarget | null = null;
@litQuery('#explore-context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
// -- ContextMenuHost interface --
// No playlist submenu — same reason as the album/artist detail
// pages: every action here resolves its one file lazily.
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
@@ -254,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;
@@ -287,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;
}
@@ -362,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;
@@ -377,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;
}
@@ -1341,11 +1379,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const owned = Boolean(target?.localId);
return html`
<wa-popup
<menu-surface
id="explore-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -1371,10 +1406,13 @@ 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}
</wa-popup>
</menu-surface>
`;
}
@@ -2173,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"
@@ -2185,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 ?? '',
},
},
)}
>
@@ -2263,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(
@@ -2273,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 */
}
@@ -24,14 +24,15 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@components/playlist-picker/playlist-picker.js';
import { dictByName } from '@utils/binding';
@@ -137,19 +138,19 @@ export class GenresView
private contextMenuGenreName: string | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// ----- ContextMenuHost interface -----
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| WaPopup
| MenuTarget
| undefined {
return this.playlistSubmenuPopup;
}
@@ -1176,11 +1177,8 @@ export class GenresView
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1284,13 +1282,12 @@ export class GenresView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1318,7 +1315,7 @@ export class GenresView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -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.
+132
View File
@@ -0,0 +1,132 @@
/**
* The phone's view of background work (#62).
*
* The header `job-indicator` is a *popover*, anchored to a bar 3.25em
* tall on a screen 439 CSS px tall, and it was reported as unreadable
* behind other UI. Two things are wrong with it there regardless of
* that symptom: a popover is a **disclosure**, and background work is
* the one thing a phone should not make you open something to see; and
* #57 deletes the bar it is anchored to, and is blocked on this issue
* precisely because the indicator needs somewhere else to live first.
*
* This is that somewhere. Below 600px the indicator stands down
* (`index.css`) and its work appears here instead.
*
* Four things about it are load-bearing.
*
* **It is the existing `job-panel`, not a second job UI.** Pause,
* cancel, Details and the log all come along and, more to the point,
* so does `applyJobControl`, which is what carries the "you will
* discard hours of downloading" confirmation for an index build. A
* host drawing its own buttons drops that silently, which is the trap
* #27 already named.
*
* **It is in the layout, not over it**, and that was measured rather
* than assumed. The first version of this put the panel in
* `notification-host`'s fixed band, which reads fine in a screenshot
* and is unusable: at 424x439 a compact panel is ~200px of a 439px
* screen, and it *covers* what is under it. Four e2e specs failed
* two phone-shell journeys and the header's action menu because the
* panel was intercepting the taps. A band that hides the app to tell
* you the app is busy is worse than the popover it replaced. In flow
* it pushes instead, so nothing is covered and nothing is unreachable,
* which is #24's one sentence across all three bands.
*
* **It shows active work only.** A finished row that lingers is a
* banner that stays after the work is done, which is the opposite of
* what #62 asks for ("dismissed automatically on completion") and, in
* flow, is furniture that keeps the content pushed down. Finished jobs
* are still shown where the work was started, which is #27's rule and
* unaffected.
*
* **It renders nothing at all above 600px**, from `matchMedia` rather
* than a media query, because this decides whether the element
* *exists*. `bottom-nav` learned that the expensive way: rendering its
* duplicate `<app-sidebar>` unconditionally put a second copy of every
* `nav-*` testid in the DOM and broke 30 specs on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so a
* fifth answering for *every* kind is the same trap.
*/
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { jobStore } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '../../utils/breakpoints';
import './job-panel';
@customElement('job-band')
export class JobBand extends LitElement {
@state() private phone = false;
@state() private active = 0;
private media?: MediaQueryList;
private unsubscribe?: () => void;
static override styles = [
designTokens,
css`
:host {
display: block;
min-width: 0;
}
/* The panel's own margin is for a settings section; here the
band owns the spacing. */
job-panel {
margin-top: 0;
padding: 0 0.5em 0.5em;
}
`,
];
private onMedia = (e: MediaQueryListEvent | MediaQueryList) => {
this.phone = e.matches;
};
private onJobs = () => {
this.active = jobStore.jobs.filter((job) => !isTerminal(job)).length;
};
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
// The band decides whether to render *at all*, and a panel that
// hides itself cannot tell its host that.
this.unsubscribe = jobStore.subscribe(this.onJobs);
this.onJobs();
void jobStore.init();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.media?.removeEventListener('change', this.onMedia);
}
override render() {
// `hidden` rather than an empty render, so the grid row this
// sits in costs nothing at all while there is no work -- the
// rule `job-panel` already follows one layer down.
this.hidden = !(this.phone && this.active > 0);
if (this.hidden) return nothing;
return html`
<job-panel kinds="*" density="compact" active-only></job-panel>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-band': JobBand;
}
}
+41 -3
View File
@@ -51,6 +51,14 @@ export class JobPanel extends LitElement {
* literal in a template, and one of them is inside an HTMX-adjacent
* settings page where a property binding would be one more thing to
* remember.
*
* **`*` means every kind**, which is the phone's band (#62) and
* nothing else: there, this panel is standing in for the header
* indicator, whose whole job was to be the one view of everything
* at once. It is spelled `*` rather than taken as the meaning of an
* empty attribute, because empty is what a typo and a missing
* binding both produce and "show everything" is the wrong thing to
* do by accident. Empty still shows nothing.
*/
@property({ type: String })
kinds = '';
@@ -59,6 +67,31 @@ export class JobPanel extends LitElement {
@property({ type: String })
heading = '';
/**
* Row density, passed to `job-row`.
*
* `full` adds elapsed time and per-job statistics and is what a
* settings section wants, so it stays the default and the four
* existing call sites are unchanged. `compact` is what `job-row`
* itself calls "the popover density", and it is what the phone's
* band uses (#62) there this panel *is* the popover, on a screen
* 439 CSS px tall, and the full density spent 259 of them.
*/
@property({ type: String })
density: 'compact' | 'full' = 'full';
/**
* Drop finished rows.
*
* For the phone's band (#62), which is *in the layout*: a finished
* row there is a banner that stays after the work is done and keeps
* the content pushed down. Settings keeps them, because that is
* where "did the last scan work" is asked, and a finished row there
* dismisses itself.
*/
@property({ type: Boolean, attribute: 'active-only' })
activeOnly = false;
@state()
private jobs: Job[] = [];
@@ -162,9 +195,14 @@ export class JobPanel extends LitElement {
}
private get mine(): Job[] {
const wanted = this.wanted;
const ofKind =
this.kinds.trim() === '*'
? this.jobs
: this.jobs.filter((job) =>
this.wanted.has(job.kind as JobKind),
);
return this.jobs.filter((job) => wanted.has(job.kind as JobKind));
return this.activeOnly ? ofKind.filter((job) => !isTerminal(job)) : ofKind;
}
private openDetails(id: string) {
@@ -196,7 +234,7 @@ export class JobPanel extends LitElement {
<div class="job-entry">
<job-row
.job=${job}
variant="full"
variant=${this.density}
@job-control=${applyJobControl}
></job-row>
<button
@@ -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
@@ -0,0 +1,363 @@
/**
* Where a context menu is drawn: a popup on a desktop, a bottom sheet
* on a phone (#60).
*
* Every context menu in this app is a `.context-menu-panel` inside a
* `<wa-popup>` anchored to the touch point, driven by
* `ContextMenuController`. On the reference device that is structurally
* broken, and the failure was measured on the hardware rather than
* inferred:
*
* - Chrome 113 has **no Popover API** (`popover` is Chrome 114), so
* `wa-popup` takes its own documented fallback and positions with
* `strategy: "fixed"` instead of the top layer. Measured on the
* device: `HTMLElement.prototype.hasOwnProperty('popover')` is false
* and the popup's computed `position` is `fixed`.
* - `index.css` puts `contain: layout style paint` on `.main-panel`,
* the ancestor of every view. Paint containment **clips** fixed
* descendants. Measured: `.main-panel` computes `contain: content`
* and spans 0-318 of a 439px viewport, while the open menu spans
* 191-401 so 83px of it, three of its seven items, is cut off.
*
* A `<dialog>` fixes it by construction rather than by styling, because
* `showModal()` is Chrome 37 and uses the real top layer. **That was
* measured too, and it needed to be**: every other dialog in this app
* is mounted in `index.html`, *outside* `.main-panel`, so "dialogs are
* fine" was not evidence about a dialog opened from inside a view. A
* probe dialog appended to `track-list`'s shadow root paints to y=439,
* over the mini player and the tab bar, with the contained ancestor
* still there.
*
* Four things about this component are load-bearing.
*
* **It is one element with two presentations, not two components.**
* The host keeps rendering exactly the panel it rendered before and
* slots it into whichever surface is up, so the twelve call sites
* changed one tag name each and nothing else no second item model, no
* second keyboard model, and `ContextMenuController` still drives
* `.active` and `.anchor` as if it were talking to a `wa-popup`.
*
* **Which surface exists is `matchMedia`, not a media query.** The
* decision is whether a `<dialog>` is in the tree at all, which is
* `job-band` and `player-controls`' rule: a `display: none` surface is
* still in the shadow root and still something a positional or by-role
* query finds.
*
* **The sheet has to un-do the UA stylesheet to be full-bleed.**
* A native `<dialog>` carries `max-width: calc(100% - 6px - 2em)` and
* `margin: auto`, which on the device produced a 354px panel floating
* in the middle of a 424px screen. `max-width: none` and explicit
* margins are what make it a sheet rather than a small centred box.
* The *positioning* needs no such care: a top-layer dialog's containing
* block is the viewport even with a paint-contained ancestor, which is
* why `bottom: 0` reaches y=439 and not the main panel's 318.
*
* **Dismissal has to travel back.** `wa-dialog` closes itself on
* Escape, which would otherwise leave the controller's
* `contextMenuOpen` true and the menu unopenable until something else
* cleared it. `menu-dismiss` is that signal, and the controller listens
* for it on the document beside the click and contextmenu listeners it
* already has.
*/
import { LitElement, css, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { PHONE_QUERY } from '@utils/breakpoints';
import { nameDialogsIn } from '@utils/name-dialog';
/** The event a surface dispatches when it closed itself. */
export const MENU_DISMISS_EVENT = 'menu-dismiss';
/**
* The event a surface dispatches once it has finished showing.
*
* Only the sheet sends it, and only because `wa-dialog` moves focus to
* itself on the frame after `showModal()` -- see `MenuKeyboard.refocus`
* for why waiting longer is not the fix.
*/
export const MENU_SHOWN_EVENT = 'menu-shown';
/**
* A `wa-popup` anchor: a real element or a virtual one.
*
* `undefined` rather than `null` for "not set yet", because that is
* what `wa-popup`'s own property accepts this surface hands the value
* straight through and must not widen it.
*/
type MenuAnchor = WaPopup['anchor'] | undefined;
/** A `wa-dialog`, as much of it as this file needs. */
type DialogEl = HTMLElement & { open: boolean };
@customElement('menu-surface')
export class MenuSurface extends LitElement {
/** Whether the menu is showing. Set by `ContextMenuController`. */
@property({ type: Boolean }) active = false;
/**
* Where the popup hangs from. Ignored in sheet mode, which is
* anchored to the bottom of the screen rather than to the touch
* point that is the whole point of a sheet.
*/
@property({ attribute: false }) anchor: MenuAnchor = undefined;
/**
* `wa-popup`'s placement, defaulted because all twelve call sites
* passed the same one. Kept as a property so a future menu that
* wants another does not have to reach past this component.
*/
@property() placement = 'bottom-start';
/**
* What to call the sheet, for a surface whose content is not a
* `.context-menu-panel` with an `aria-label` of its own -- the
* playlist submenu, whose content is a `playlist-picker`.
*/
@property() label = '';
@state() private sheet = false;
@query('wa-popup') private popup?: WaPopup;
@query('wa-dialog') private dialog?: DialogEl;
private phoneQuery?: MediaQueryList;
static override styles = css`
:host {
display: contents;
}
wa-popup {
z-index: 200;
}
/* The sheet. A native dialog's UA stylesheet centres it and
caps its width, which on the device drew a 354px box in the
middle of a 424px screen so all four of these are undoing
that rather than decorating. */
wa-dialog::part(dialog) {
margin: auto auto 0 auto;
max-width: none;
max-height: 85vh;
width: 100%;
border-radius: 12px 12px 0 0;
background: var(--yj-bg-elevated, #343a40);
padding: 0;
}
/* **A long menu scrolls; it does not hang off the bottom.**
Measured on the device at 80vh: seven 48px rows plus the grip
came to 364px against a 351px dialog, so the last row's
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.
**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
edge is. Decorative: the panel below it carries the actions. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
}
`;
override connectedCallback(): void {
super.connectedCallback();
// Looked up here rather than at module load, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.sheet = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneChange);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.phoneQuery?.removeEventListener('change', this.onPhoneChange);
}
private onPhoneChange = (e: MediaQueryListEvent): void => {
this.sheet = e.matches;
};
/**
* Re-run the popup's positioning.
*
* Forwarded rather than dropped because `page-header` calls it when
* it opens the overflow menu: the popup is rendered before the
* button it anchors to has settled. A sheet has nothing to
* reposition -- it is anchored to the bottom of the screen -- so
* there it is deliberately a no-op rather than an error.
*/
reposition(): void {
this.popup?.reposition();
}
/**
* The panel the host slotted in. It is light DOM here and stays in
* the host's shadow root, which is what keeps the host's own
* `contextMenuStyles` applying to it in both presentations.
*/
private get panel(): HTMLElement | null {
return this.querySelector('.context-menu-panel');
}
override updated(): void {
const panel = this.panel;
// The sheet's rows are bigger, and that rule lives in the one
// stylesheet every call site already includes rather than in
// twelve places. The attribute is how it knows.
if (panel) panel.toggleAttribute('data-sheet', this.sheet);
if (this.sheet) {
this.syncSheet(panel);
return;
}
if (this.popup) {
if (this.anchor) this.popup.anchor = this.anchor;
this.popup.active = this.active;
}
}
private syncSheet(panel: HTMLElement | null): void {
const dialog = this.dialog;
if (!dialog) return;
// The dialog is named after the menu it contains, so no call
// site has to say the same thing twice: the panel already
// carries `role="menu"` and an `aria-label` naming what it acts
// on. `without-header` renders no heading, which is
// `name-dialog`'s documented `aria-label` path.
const label = panel?.getAttribute('aria-label') || this.label;
if (label) dialog.setAttribute('label', label);
nameDialogsIn(this.shadowRoot);
if (dialog.open !== this.active) dialog.open = this.active;
}
/**
* `wa-dialog` closed itself Escape, or its own close button.
* The controller owns `contextMenuOpen`, so it has to hear about
* it or the menu is left open in state and shut on screen.
*/
private onDialogShown = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_SHOWN_EVENT, {
bubbles: true,
composed: true,
}),
);
};
private onDialogHide = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_DISMISS_EVENT, {
bubbles: true,
composed: true,
}),
);
};
override render() {
if (this.sheet) {
// **The anchor stays out of the sheet.** One call site --
// `page-header`'s overflow menu -- slots its own trigger
// button as the thing the popup hangs from, and a sheet
// hangs from the bottom of the screen instead. Rendering
// that slot outside the dialog is what keeps the button on
// the page rather than inside the surface it opens.
return html`
<slot name="anchor"></slot>
<wa-dialog
without-header
data-testid="menu-sheet"
@wa-after-show=${this.onDialogShown}
@wa-hide=${this.onDialogHide}
>
<div class="grip"></div>
<slot></slot>
</wa-dialog>
`;
}
return html`
<wa-popup placement=${this.placement} flip shift>
<slot name="anchor" slot="anchor"></slot>
<slot></slot>
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'menu-surface': MenuSurface;
}
}
@@ -15,6 +15,7 @@ import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { ICON_QUEUE } from '@utils/icon-language';
import { openQueue as showQueue } from '@utils/open-queue';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
@@ -104,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;
@@ -112,19 +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. */
width: min(100%, 60vh);
.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.
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.
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;
@@ -212,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() {
@@ -226,13 +364,15 @@ export class NowPlayingView extends LitElement {
*
* This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen
* would take the queue away. It toggles the same `open` attribute
* would take the queue away. It goes through the same helper
* `index.ts` does, because the panel's state is an attribute on one
* element and a second mechanism for it is a second thing to keep
* in step.
* in step -- which is exactly what this button was: it set `open`
* directly, so on a phone it produced a queue with no history entry
* behind it and back moved the page underneath instead (#55).
*/
private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', '');
showQueue();
}
private toggleFavorite() {
@@ -261,60 +401,84 @@ 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">
<seek-bar></seek-bar>
<player-controls></player-controls>
<!-- context="full": this view *is* the player, so the
transport is the page rather than a strip of it --
primary controls large, shuffle and repeat beneath
at normal size (#56). It is a property rather than
a media query because the bottom bar wants a
different answer at this same viewport. -->
<player-controls context="full"></player-controls>
<!-- Rendered unconditionally and absent on its own
terms where the device owns the volume (#64): the
control asks the player, not this view and not the
viewport. A hidden host draws no gap, so that is
29px of a 439px screen back to the album art
(#172). -->
<volume-control></volume-control>
</div>
`;
@@ -215,6 +215,17 @@ export class NowPlaying extends LitElement {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
/* The favourite is one of the three controls #59 keeps on the
phone's bar, and it was the **smallest control in the app**:
measured at 424x439, 18x14px, against the 48x48 art beside it.
Zero padding around an icon-sized glyph is a reasonable mouse
target and is not a thumb target at all. */
.fav-btn {
min-width: 44px;
min-height: 44px;
font-size: var(--yj-icon-md);
}
}
.cover-preview-panel {
@@ -446,8 +457,30 @@ export class NowPlaying extends LitElement {
return html`
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
<!-- **The way to Now Playing does not depend on what is
playing.** This branch used to render the placeholder
with no button on it, so on a phone there was no route to
the full-screen view while nothing was loaded -- and once
#59 took the queue button off the bar, that made the
queue itself unreachable, because Now Playing is where it
is reached from. The queue is persisted across restarts,
so "a queue with tracks in it and nothing playing" is an
ordinary state to launch into, not a corner.
Plan 018's matrix promises no action is unreachable at
any supported size, and the promise is what makes #59
allowed to remove a control at all. -->
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
</div>
</div>
</div>
<div
@@ -1,9 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import { designTokens } from '../../styles/tokens.css';
import {
@@ -11,6 +11,7 @@ import {
contextMenuStyles,
} from '../../utils/context-menu-controller';
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
import '../search-dialog/search-trigger';
/**
* The one arrangement every primary view uses to say what it is.
@@ -161,6 +162,15 @@ export class PageHeader extends LitElement {
@state()
private collapsed: ReadonlySet<string> = new Set();
/**
* Whether the count has been given up. Derived, like `collapsed`.
*
* It is the last thing to yield and the only thing here that is
* neither an identity nor an action see `measureFit`.
*/
@state()
private countCollapsed = false;
@state()
private menuOpen = false;
@@ -173,8 +183,8 @@ export class PageHeader extends LitElement {
@query('#page-header-overflow')
private menuPanel?: HTMLElement;
@query('wa-popup')
private popup?: WaPopup;
@query('menu-surface')
private popup?: MenuSurface;
private menuKeyboard = new MenuKeyboard(() => this.closeMenu());
@@ -288,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;
@@ -296,6 +347,7 @@ export class PageHeader extends LitElement {
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
min-block-size: 44px;
}
.sort-dir {
@@ -308,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 {
@@ -367,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
@@ -408,7 +482,7 @@ export class PageHeader extends LitElement {
outline-offset: -1px;
}
wa-popup {
menu-surface {
z-index: 200;
}
@@ -457,6 +531,20 @@ export class PageHeader extends LitElement {
${this.renderCount()}
<div class="spacer"></div>
${this.renderScope()} ${this.renderSort()}
<!-- #57. Below 600px the top bar is out of the layout,
so the search box has to be reachable from here.
It renders nothing at every other width and on
every view search-store says has nothing to
search, which is why no host declares it: the map
of searchable views already exists and this is one
more reader of it, not a second copy.
Before the actions, and never one of them: an
action can collapse into the overflow menu, and on
a phone that menu is already where the page's own
actions live -- search behind an ellipsis is the
top bar's problem moved rather than fixed. -->
<search-trigger></search-trigger>
${this.renderActions()}
<slot name="actions"></slot>
</header>
@@ -516,12 +604,6 @@ export class PageHeader extends LitElement {
if (!header) return;
if (this.actions.length === 0) {
this.commitCollapsed(new Set());
return;
}
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
@@ -534,6 +616,7 @@ export class PageHeader extends LitElement {
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
const count = this.renderRoot.querySelector<HTMLElement>('.count');
/**
* Nothing is clipped which is not the same as the header not
@@ -555,6 +638,8 @@ export class PageHeader extends LitElement {
if (more) more.hidden = true;
if (count) count.hidden = false;
const collapsed = new Set<string>();
if (!fits()) {
@@ -571,7 +656,42 @@ export class PageHeader extends LitElement {
}
}
this.commitCollapsed(collapsed);
this.commitCollapsed(collapsed, this.collapseCount(count, fits));
}
/**
* The last thing to give way, after every action is in the menu and
* the title has already run out.
*
* There are four things competing for this row and three of them
* cannot go. The **title** yields first and is allowed to ellipsis
* away entirely at 320px, because the navigation also says which
* page you are on. The **sort** control and the **actions** are
* each the only place they are said, so an action collapses into
* the menu rather than disappearing and the sort control stays.
* That leaves the **count**, which is the one purely informational
* item on the row an empty page says so in its empty state, and a
* full one is being looked at.
*
* It became reachable rather than theoretical with #57: below 600px
* the header also carries the phone's search button, and on
* Playlists at 320px that is 43px more than the row has. Measured
* there: title 0, count 50, sort 143, search 40, "More actions" 38,
* five 12px gaps and 32px of gutters 363 in 320, with the More
* button ending 27px past the edge. Something has to go, and this
* is the only candidate that is not an action.
*
* @returns whether the count was given up.
*/
private collapseCount(
count: HTMLElement | null,
fits: () => boolean,
): boolean {
if (count === null || fits()) return false;
count.hidden = true;
return true;
}
/** Lowest priority first; ties broken from the right. */
@@ -586,7 +706,9 @@ export class PageHeader extends LitElement {
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
private commitCollapsed(next: Set<string>, countHidden: boolean): void {
this.countCollapsed = countHidden;
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
@@ -612,10 +734,17 @@ export class PageHeader extends LitElement {
return html`
<div class="actions">
${this.actions.map((a) => this.renderActionButton(a))}
<wa-popup
<!-- A sheet below 600px, like every other menu in the
app (#60). The clipping that issue is about does
not bite here this one opens downward from the
top of a full-height view, so it has somewhere to
go even without top-layer promotion but the touch
targets do: on a phone *every* action of a page
that overflows lives in here, at wa-dropdown-item
defaults. One surface, so there is no second
answer to what a menu looks like. -->
<menu-surface
placement="bottom-end"
flip
shift
.active=${this.menuOpen}
>
<button
@@ -653,7 +782,7 @@ export class PageHeader extends LitElement {
`,
)}
</div>
</wa-popup>
</menu-surface>
</div>
`;
}
@@ -754,7 +883,16 @@ export class PageHeader extends LitElement {
const noun = this.count === 1 ? this.countNoun : plural;
return html`<span class="count" data-testid="page-count"
// Rendered whether or not it fits, and hidden with an
// attribute -- the same shape the action buttons use, and for
// the same reason: `measureFit` starts every pass from
// all-visible, so it needs a node to un-hide. Returning
// `nothing` here would take the count away for the rest of the
// session the first time a 320px window appeared.
return html`<span
class="count"
data-testid="page-count"
?hidden=${this.countCollapsed}
>${this.count.toLocaleString()} ${noun}</span
>`;
}
@@ -7,7 +7,8 @@ import {
} from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
@@ -27,6 +28,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -34,8 +36,12 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } 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';
@@ -68,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*,
@@ -144,10 +156,10 @@ export class PlaylistDetails
private dragImageEl: HTMLElement | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@@ -162,11 +174,11 @@ export class PlaylistDetails
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -419,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,
@@ -458,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
// =================================================================
@@ -952,8 +1107,11 @@ export class PlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -979,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;
}
@@ -1039,6 +1172,16 @@ export class PlaylistDetails
min-width: 0;
}
/* #57. This view is in search-store's map and filters on the
term, but it is a detail view and so has no page-header to
carry the phone's search button. Pushed to the end of the
header row, which is where page-header puts it too. */
.header-end {
margin-left: auto;
display: flex;
align-items: center;
}
.playlist-title {
font-size: 24px;
font-weight: 700;
@@ -1147,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 {
@@ -1211,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 {
@@ -1232,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 {
@@ -1246,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;
@@ -1383,6 +1550,9 @@ export class PlaylistDetails
`
: ''}
</div>
<div class="header-end">
<search-trigger></search-trigger>
</div>
</div>
${searchBar}
<div
@@ -1441,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"
@@ -1450,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()}
`;
}
@@ -1472,6 +1651,7 @@ export class PlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1482,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)}
@@ -1528,6 +1709,7 @@ export class PlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div
class="phantom-row"
@@ -1603,11 +1785,8 @@ export class PlaylistDetails
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1766,16 +1945,23 @@ export class PlaylistDetails
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1802,7 +1988,7 @@ export class PlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -2,7 +2,9 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 {
@@ -140,7 +142,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
private pendingDropPaths: string[] = [];
@query('#playlist-context-menu')
private playlistContextMenuPopup!: WaPopup;
private playlistContextMenuPopup!: MenuSurface;
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
@@ -1059,7 +1061,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
);
}
private closePlaylistContextMenu() {
private closePlaylistContextMenu = () => {
if (!this.playlistContextMenuOpen) return;
this.menuKeyboard.close();
@@ -1072,7 +1074,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
if (popup) {
popup.active = false;
}
}
};
private async onPlaylistContextAction(
action: string,
@@ -1500,13 +1502,11 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
</div>`
: this.renderPlaylistList()}
<wa-popup
<menu-surface
id="playlist-context-menu"
placement="bottom-start"
flip
shift
.active=${this
.playlistContextMenuOpen}
@menu-dismiss=${this.closePlaylistContextMenu}
>
${this.playlistContextMenuOpen
? html`
@@ -1564,7 +1564,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<duplicate-tracks-dialog
@playlist-action-complete=${() =>
@@ -9,9 +9,11 @@ import {
} from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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,
@@ -34,7 +36,7 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
@@ -60,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;
@@ -119,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;
@@ -140,13 +168,13 @@ export class QueuePanel
private delegationAttached = false;
@query('#add-to-playlist-popup')
private addToPlaylistPopup!: WaPopup;
private addToPlaylistPopup!: MenuSurface;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
/** Unsubscribes the credit-arrival repaint. */
private creditsUnsub?: () => void;
@@ -305,11 +333,11 @@ export class QueuePanel
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -390,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;
@@ -410,6 +440,22 @@ export class QueuePanel
:host([overlay]) .panel-content {
width: 100%;
}
/* 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 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
them and 44px of header is 44px the queue does not get. */
:host([overlay]) .header-action-button {
min-width: 44px;
min-height: 44px;
justify-content: center;
}
}
.resize-handle {
@@ -535,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 {
@@ -551,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);
@@ -622,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 {
@@ -786,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;
}
@@ -811,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(),
@@ -853,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',
@@ -896,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;
}
@@ -919,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.
@@ -1076,7 +1176,7 @@ export class QueuePanel
}
}
private closePlaylistPicker() {
private closePlaylistPicker = () => {
if (!this.playlistPickerOpen) return;
this.playlistPickerOpen = false;
@@ -1086,7 +1186,7 @@ export class QueuePanel
if (popup) {
popup.active = false;
}
}
};
private onPlaylistActionComplete = () => {
this.closePlaylistPicker();
@@ -1177,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,
@@ -1459,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)
// =================================================================
@@ -1955,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"
@@ -2026,9 +2235,11 @@ export class QueuePanel
</div>
</div>
<wa-popup
<menu-surface
id="add-to-playlist-popup"
label="Add to playlist"
placement="bottom-end"
@menu-dismiss=${this.closePlaylistPicker}
.active=${this.playlistPickerOpen}
>
${this.playlistPickerOpen
@@ -2044,7 +2255,7 @@ export class QueuePanel
></playlist-picker>
`
: nothing}
</wa-popup>
</menu-surface>
<div
class="list-area"
@@ -2085,13 +2296,11 @@ export class QueuePanel
></lit-virtualizer>
`}
</div>
${this.renderSelectionBar()}
</div>
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -2176,16 +2385,23 @@ export class QueuePanel
Track
Details
</wa-dropdown-item>
${goToMenuItems(this.goToTarget, {
onSelect: () => {
this.selection.clear();
this.ctxMenu.close();
},
onHover: () =>
this.ctxMenu.closePlaylistSubmenu(),
})}
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen &&
@@ -2208,7 +2424,7 @@ export class QueuePanel
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
@@ -62,7 +62,10 @@ export class SearchBar extends LitElement {
gap: 8px;
height: 32px;
min-width: 200px;
max-width: 360px;
/* A cap for a header, not for the box. search-dialog gives
it the whole of a modal, where 360px of a 424px screen
would read as a control that failed to size itself. */
max-width: var(--yj-search-max-width, 360px);
width: 100%;
transition: border-color 0.15s ease;
}
@@ -0,0 +1,210 @@
/**
* The phone's search surface (#57).
*
* Below 600px there is no top bar to hold a search box the bar is out
* of the layout entirely, which is the single biggest vertical win
* available on a 439 CSS px viewport. So the box moves into a modal and
* the *trigger* moves into the row that already says which page you are
* on (`search-trigger`, beside this file).
*
* **It is a `wa-dialog`, and that is a mechanism rather than a taste.**
* #60 read this out of the Web Awesome source: `wa-popup` renders
* `<div popover="manual">` and feature-detects the Popover API, falling
* back to `strategy: "fixed"` where there is none which is the
* reference device, Chrome 113, since `popover` is Chrome 114. And
* `position: fixed` escapes ancestor *overflow* but not `contain:
* paint`, which makes an element a containing block for fixed
* descendants **and clips them**; `index.css` puts `contain: layout
* style paint` on `.main-panel`, which is the ancestor of every view.
* A popup-shaped search panel opened from a view's header would
* therefore be structurally clipped on the one device this issue is
* about, and **no tier here could see it** CI's Chromium and WebKit
* both have the Popover API, so the popup is top-layered and correct.
* `<dialog>`/`showModal()` is Chrome 37 and uses the real top layer, so
* this is immune by construction.
*
* **It carries the real `<search-bar>`**, not a second input. That is
* what keeps one debounce, one clear button, one accessible name and
* one view-scoped placeholder and it is why `store/search-store.ts`
* is still the only statement of which views can search and what they
* search. The modal is a presentation of the control, not a copy of it.
*
* **The results are the view, not a list in here.** The Direction says
* "the box and live results"; the live results already exist, because
* the term is view-scoped and the page behind this dialog filters on it
* and says so in `page-header`'s "Showing albums matching …" line.
* Rendering results in the dialog would be a second implementation of
* every view's own filtering, and a worse one it could not offer the
* row actions the view does. So Enter closes and hands the screen back.
*
* A singleton in `index.html` for the reason `shortcuts-overlay` is:
* one instance, one `data-testid`, one document listener, and no
* `data-testid="search-input"` resolving to two elements while it is
* shut.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import { designTokens } from '../../styles/tokens.css';
import { nameDialogsIn } from '@utils/name-dialog';
import { SearchController } from '@store/controllers/search-controller';
import type { SearchBar } from '../search-bar/search-bar';
import '../search-bar/search-bar';
/** The event any trigger dispatches to open this. */
export const OPEN_SEARCH_EVENT = 'open-search';
@customElement('search-dialog')
export class SearchDialog extends LitElement {
private searchCtrl = new SearchController(this);
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@query('search-bar') private bar?: SearchBar;
@state() private isOpen = false;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
/* The box is the whole content, so it gets the whole width
rather than the 360px cap it wears in a header. */
search-bar {
display: block;
width: 100%;
--yj-search-max-width: none;
}
.hint {
margin: 0.75em 0 0;
font-size: var(--yj-text-sm, 0.8125rem);
color: var(--yj-text-secondary, #b3b3b3);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener(OPEN_SEARCH_EVENT, this.open);
// Capture, on the host: the path runs document -> host ->
// shadow root -> the input inside `search-bar`, so a capture
// listener here is the only one that gets the key *before* the
// input's own handler. A `@keydown` in the template is a
// bubbling listener and would run after the term was cleared,
// and there is nowhere to put a `firstUpdated` hook -- the
// first render of this element produces no content at all.
this.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener(OPEN_SEARCH_EVENT, this.open);
this.removeEventListener('keydown', this.onKeydown, true);
}
/**
* Not a toggle, for `shortcuts-overlay`'s reason: a dialog owns
* every unmodified key while it is up, so a second press of the
* shortcut that opened it never reaches the shortcut service.
*/
private open = (): void => {
if (this.isOpen) return;
// Nothing to search here is not an error; it is the state the
// trigger already declines to render in. Guarding here too is
// what makes the keyboard route (Ctrl+F on a phone) agree with
// the button.
if (!this.searchCtrl.isSearchableView) return;
this.isOpen = true;
void this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
// `wa-dialog` positions and shows in its own update, and
// `search-bar` populates its own shadow root in one more —
// the same lifecycle trap `name-dialog.ts` documents. One
// more frame, and the box has an input to focus.
requestAnimationFrame(() => this.bar?.focusInput());
});
};
private close(): void {
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
}
/**
* Escape closes and **keeps the term**; Enter closes and shows the
* results.
*
* Escape is the one worth stating. `search-bar`'s input treats it
* as *clear the search*, which is right in a header the box is on
* screen either way, so clearing is the only thing left for the key
* to mean. Here it would make dismissing the search surface
* silently discard the search, and discarding is what the clear
* button inside it is for. So this runs first and closes; the term
* survives, and the page behind is still filtered by it.
*/
private onKeydown = (e: KeyboardEvent): void => {
if (!this.isOpen) return;
if (e.key === 'Escape') {
e.stopPropagation();
this.close();
return;
}
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
this.close();
}
};
/**
* Web Awesome renders `label` into a heading it never points the
* `<dialog>` at. See `utils/name-dialog.ts`.
*/
override updated(): void {
nameDialogsIn(this.shadowRoot);
}
override render() {
if (!this.isOpen) return nothing;
const scope = this.searchCtrl.scopeLabel;
return html`
<wa-dialog
label=${`Search ${scope}`}
data-testid="search-dialog"
@wa-hide=${() => this.close()}
>
<search-bar></search-bar>
<p class="hint">
Results appear on the page behind this. Press Enter
or close to see them.
</p>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-dialog': SearchDialog;
}
}
@@ -0,0 +1,168 @@
/**
* The phone's way into search (#57): one button, in the row that
* already says which page you are on.
*
* **Which views show it is not a decision this component makes.**
* `store/search-store.ts` has held the map of what each view searches
* since plan 007, and #57's own Findings say so "that is exactly the
* condition for showing the button". So this asks `isSearchableView`
* and renders nothing otherwise, and no second list of searchable views
* exists to fall out of step with the first.
*
* **It is an element rather than a `PageAction`**, and that is the
* whole reason it is a component at all. Two of the seven searchable
* views `playlist-details` and `smart-playlist-details` have no
* `page-header`; they filter on the term and say so in their own
* headers. Declaring search as an action would mean seven hosts each
* writing it out, which is the second list again, and it would put a
* *phone mode for actions* inside `page-header`, which that component
* documents its refusal to grow. An element three headers place is one
* statement of the rule, placed three times.
*
* It does not participate in `page-header`'s overflow measurement, for
* the reason the count and the sort control do not: it is 32px, it is
* `flex-shrink: 0`, and the header's `fits()` sees its width like any
* other child. What it must never do is collapse into the overflow
* menu on a phone that menu is the only home for the page's actions
* already, and search would be two taps behind an ellipsis.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '@utils/breakpoints';
import { SearchController } from '@store/controllers/search-controller';
import { ICON_SEARCH } from '@utils/icon-language';
import { OPEN_SEARCH_EVENT } from './search-dialog';
@customElement('search-trigger')
export class SearchTrigger extends LitElement {
private searchCtrl = new SearchController(this);
/**
* From `matchMedia` rather than a media query, because this decides
* whether the button *exists* `job-band`'s rule, and for the same
* consequence: a header that renders it at every width puts a
* second search affordance beside the desktop's own box.
*/
@state() private phone = false;
private media?: MediaQueryList;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
/* 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);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
/* A search that is *on* says so without a second control:
the page already carries "Showing albums matching ...",
and this is the button that reopens the box to change or
clear it. */
button.filtering {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.media?.removeEventListener('change', this.onMedia);
}
private onMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private onClick = (): void => {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
};
override render() {
if (!this.phone || !this.searchCtrl.isSearchableView) return nothing;
const scope = this.searchCtrl.scopeLabel;
const term = this.searchCtrl.term;
// The name carries the state, because the colour cannot: a
// control that is a different colour and the same word is a
// control that says nothing to anyone not seeing it. Same rule
// `library-status.ts` states for a partial badge.
const label = term
? `Search ${scope}, showing matches for ${term}`
: `Search ${scope}`;
return html`
<button
data-testid="search-trigger"
class=${term ? 'filtering' : ''}
aria-label=${label}
title=${label}
@click=${this.onClick}
>
<wa-icon name=${ICON_SEARCH}></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-trigger': SearchTrigger;
}
}
@@ -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;
@@ -18,6 +18,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -25,8 +26,12 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } 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,
@@ -39,7 +44,8 @@ import {
} from '@utils/drag-image';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
@@ -58,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,
@@ -175,10 +187,10 @@ export class SmartPlaylistDetails
private dragImageEl: HTMLElement | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@@ -187,11 +199,11 @@ export class SmartPlaylistDetails
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -240,8 +252,11 @@ export class SmartPlaylistDetails
static override styles = [
designTokens,
srOnly,
backButton,
contextMenuStyles,
exploreLinkStyles,
swipeRevealStyles,
css`
:host {
display: flex;
@@ -267,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;
}
@@ -359,6 +349,15 @@ export class SmartPlaylistDetails
flex-shrink: 0;
}
/* #57. Like playlist-details, this view filters on the search
term and has no page-header to carry the phone's search
button, so the action row does. */
.actions-end {
margin-left: auto;
display: flex;
align-items: center;
}
.action-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -456,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 {
@@ -514,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 {
@@ -531,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;
@@ -928,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();
@@ -1300,6 +1447,9 @@ export class SmartPlaylistDetails
Edit Rules
</button>
`}
<div class="actions-end">
<search-trigger></search-trigger>
</div>
</div>
${this.editing
? html`
@@ -1343,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"
@@ -1351,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()}
`;
}
@@ -1373,6 +1532,7 @@ export class SmartPlaylistDetails
active ? 'active' : '',
selected ? 'selected' : '',
isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
]
.filter(Boolean)
.join(' ');
@@ -1383,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)}
@@ -1417,6 +1578,7 @@ export class SmartPlaylistDetails
? nothing
: this.onTrackDragEnd}
>
${this.swipe.renderReveal(trackIndex)}
${isPhantom
? html`<div class="phantom-row">
<wa-icon
@@ -1452,11 +1614,8 @@ export class SmartPlaylistDetails
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -1567,16 +1726,23 @@ 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}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1603,7 +1769,7 @@ export class SmartPlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -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;
+245 -18
View File
@@ -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 {
@@ -18,7 +22,7 @@ import {
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
@@ -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,
@@ -63,7 +69,8 @@ import type {
} from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
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 '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
@@ -76,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';
@@ -231,18 +240,18 @@ export class TrackList
private tracks: library.Track[] = [];
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -276,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();
};
@@ -1010,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;
@@ -1169,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 {
@@ -1207,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;
@@ -1303,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);
}
@@ -1444,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;
}
@@ -1707,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);
@@ -1830,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();
@@ -2128,6 +2306,7 @@ export class TrackList
'track-row': true,
active,
selected,
swiping: this.swipe.isSwiping(index),
})}
role="row"
aria-rowindex=${index + 1}
@@ -2137,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({
@@ -2237,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;
@@ -2246,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`
@@ -2321,13 +2544,11 @@ export class TrackList
)}
</div>
</div>
${this.renderSelectionBar()}
`}
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -2396,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(
@@ -2413,13 +2641,12 @@ export class TrackList
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection
@@ -2437,7 +2664,7 @@ export class TrackList
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
@@ -16,6 +16,7 @@ import { playerStore } from '@store/player-store';
import { queueStore } from '@store/queue-store';
import * as Player from '@go/player/player.js';
import type { SearchBar } from '@components/search-bar/search-bar';
import { OPEN_SEARCH_EVENT } from '@components/search-dialog/search-dialog';
// ===================================================================
// KEY STRING UTILITIES
@@ -387,14 +388,24 @@ async function dispatch(action: string): Promise<void> {
break;
// Navigation
// The key has one meaning -- *let me search this page* -- and
// two surfaces since #57. The header box is gone below 600px,
// so scoping the query to the bar is not tidiness: an unscoped
// `search-bar` also matches the one inside `search-dialog`
// while that is open, and would focus a box the user is
// already typing in while leaving the phone with nothing at
// all. The dialog declines to open on a view with nothing to
// search, which is the same condition the trigger renders on.
case 'nav.search':
case 'nav.searchAlt': {
const bar = document.querySelector(
'search-bar',
'header.top-bar search-bar',
) as SearchBar | null;
if (bar && !bar.hasAttribute('hidden')) {
if (bar && bar.checkVisibility()) {
bar.focusInput();
} else {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
}
break;
+10
View File
@@ -119,6 +119,16 @@ export const FIT_STEPS: readonly FitStep[] = [
* @returns the ids collapsed, in the order they were given up.
*/
export function measureTopBarFit(bar: HTMLElement): string[] {
// Below 600px there is no bar to fit (#57): `index.css` takes it
// out of the grid and leaves it visually hidden at 1px, carrying
// nothing but the document's `h1`. Measuring that reports the
// wordmark as overflowing 1px of content box and collapses it every
// time -- true, and about nothing, since the whole bar is already
// invisible. Asking the *computed position* rather than the
// viewport width is what keeps this file free of a breakpoint the
// stylesheet already owns.
if (getComputedStyle(bar).position === 'absolute') return [];
const fits = () => {
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
+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.
+59 -1
View File
@@ -1,5 +1,6 @@
import { EventsOn } from '@runtime/runtime';
import { GetPopupVolume } from '@go/config/config.js';
import { SystemOwnsVolume } from '@go/player/player.js';
import { Events } from '../events';
type Subscriber = () => void;
@@ -28,10 +29,37 @@ type Subscriber = () => void;
* becomes one. An install that has chosen the popup sees it swap once
* on load, which is the cheaper of the two wrong first frames: the
* inline slider occupies the space the popup's button would have.
*
* **`available` is the question one step earlier whether there is a
* volume of ours to draw at all (#64).** On Android the hardware keys
* are the volume control and the backend pins its own level at
* maximum, so a slider here would move nothing.
*
* It is asked of the *player* rather than of the viewport, and that is
* the whole design decision. Every other stand-down rule in this app
* is a width, because a width is what a browser can answer and what
* every tier can test but this one is a property of the build. Keyed
* on width instead, an Android tablet at 600px or more would draw the
* bottom bar's slider over a pinned level: a control that cannot act,
* which `library-status-indicator` settled is worse than none.
*
* It lives beside `popup` because both answer "what presentation does
* the volume control get", both readers are the same two components,
* and "none" is a presentation. A second store would be a second
* subscription in the same `connectedCallback` saying the same thing.
*
* The initial value is `true` on the same first-frame rule: there is a
* volume on every platform but one, and the platform that pins it sees
* the control once at boot and never again in the session the answer
* cannot change while the app runs, so by the time the lazily-mounted
* now-playing view exists it has long been settled by the bar's own
* copy.
*/
class VolumeStyleStore {
private value = false;
private hasVolume = true;
private loaded = false;
private subscribers = new Set<Subscriber>();
@@ -47,13 +75,21 @@ class VolumeStyleStore {
return this.value;
}
/**
* Whether this app has a volume of its own to control. False where
* the device owns it; see the class comment.
*/
get available(): boolean {
return this.hasVolume;
}
/** Reads the setting once. Safe to call from every mount. */
async init(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
await this.refresh();
await Promise.all([this.refreshAvailability(), this.refresh()]);
}
subscribe(fn: Subscriber): () => void {
@@ -77,6 +113,28 @@ class VolumeStyleStore {
}
}
/**
* Asked once, not on `GeneralConfigChanged`: this is a property of
* the platform the binary was built for and cannot change while
* the app is running.
*/
private async refreshAvailability(): Promise<void> {
try {
const owned = await SystemOwnsVolume();
if (owned === !this.hasVolume) return;
this.hasVolume = !owned;
this.notify();
} catch (err) {
// The control renders, which is the answer on every
// platform but one and is the recoverable way to be wrong:
// a working control nobody needs, rather than a missing one
// somebody does.
console.error('failed to ask who owns the volume', err);
}
}
private notify(): void {
for (const fn of this.subscribers) fn();
}
+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));
}
`;

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