Compare commits

..
Author SHA1 Message Date
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
logan 998ce75fb6 docs(android): the identity is read back, not declared twice
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m22s
android-tier.md carried a warning block telling the reader not to use
run:device or deploy-device, and offered a manual sequence instead.
Both are wrong now: the tasks are the way in, and the warning would
read as a live hazard. It becomes a note about what changed, and the
manual sequence stays as the smallest thing that works when you want no
script between you and adb.

"The identity is declared twice" was the section this file had carried
for five phases saying nothing enforced that the two ids agree. It
describes the enforcement now, plus what APP_ID means since it stopped
being a setting it never was.

NOTES.md takes the four measurements: that the uninstall existed only
to cover a missing -r (which is what makes deleting it a fix rather
than a trade), that the emulator tasks installed on a phone, what
reading the id back costs, and the boot-wait race filed as #162.
2026-08-20 14:35:16 -04:00
logan 4b392cb4c4 fix(android): point the emulator script at the built APK's id
The third declaration of the app's identity, and the one #159 did not
cash out in: PKG defaulted to "app.yellowjacket" while
`make android-install` installs whatever is in bin/, which after
`wails3 task android:assemble:apk` is app.yellowjacket.dev. So
android-launch, android-logs and android-smoke addressed a package the
build had not produced, and the certificate-change message named the
wrong id to uninstall -- the release one.

It is derived from bin/yellowjacket.apk the same way the tasks are, so
it follows whichever variant was built last. YJ_ANDROID_PKG still
overrides, and the literal survives only for a tree with no APK yet,
where these commands are asking about whatever is already installed and
there is nothing to read.

cmd_inspect's probe order goes with it: "$PKG.dev" would append a
second suffix to an id that already carries one, so the candidates are
derived from the resolved id in either direction -- debug sibling
first, release second, as before.
2026-08-20 14:35:08 -04:00
logan 8d2109b87e fix(android): install and launch the package the APK declares
The four adb-driven tasks in build/android/Taskfile.yml began with
`adb uninstall {{.APP_ID}}`, where APP_ID defaulted to
"app.yellowjacket" -- the release id. `run` and `run:device` build the
*debug* variant, whose applicationIdSuffix makes it
"app.yellowjacket.dev", so both uninstalled the user's released app,
took the library with it, installed a different package, and then
failed to launch the one they had just removed.

The id is read back from the built APK now (scripts/android-pkgid.sh,
`aapt2 dump packagename`) rather than written down a second time, so
the thing installed and the thing launched agree by construction --
whatever Gradle resolved the applicationId to, suffixes included, is in
the file. An APK it cannot read is a hard failure and never a fallback
to a default; guessing is the bug. APP_ID survives with no default as
an *assertion*: it is checked against the artifact and refused, naming
both, before anything is installed or a target is even chosen.

The uninstall is gone rather than corrected. It was there to make the
bare `install` on the next line work at all -- Android refuses an
install over an existing package without -r -- so `install -r` removes
the reason for it. What is left is the one case an uninstall is really
the remedy, a changed signing certificate, and that is exactly the case
where doing it silently costs the user their library. So it is reported
with the command to run, which is the answer scripts/android-emulator.sh
had already reached for `make android-install`.

And the emulator tasks now say "emulator" to adb. A bare `adb install`
with one device attached picks that device whatever it is, so with a
phone plugged in and no emulator running, the task whose summary reads
"in the Android Emulator" installed on the phone -- the same data loss,
from the task whose name gives no warning. Several matching targets is
an error naming them rather than a silent pick of the first.

Closes #159
2026-08-20 14:34:59 -04:00
logan b741b01cdf Merge pull request 'Android: run main() once per process, not once per activity' (#161) from fix/52-android-activity-recreation-restarts-the-process into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 8m19s
2026-08-20 17:18:19 +00:00
logan 8a757c9bb4 docs(android): record the lifecycle model and the device check
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m9s
The lifecycle answer is load-bearing, so CLAUDE.md states it: an
activity is a view onto the process, and main() runs once per process.
The "restore the session or cold-start" question the issue asks for a
decision on is settled by playback rather than by preference -- the
audio lives in the Go process, so a cold start on every recreation
stops the music mid-song, which is the thing the foreground service
exists to prevent.

android-tier.md's build table said "arm64, real device -- unverified,
still" for five phases. It is verified now, on a Light Phone III
(Android 14, arm64-v8a, WebView Chrome 113 at 424x439), and what the
run found is a lifecycle section: how to force an activity recreation
on demand, the three-line logcat signature, why `has died: fg TOP` is
not a memory kill, and the second assertion that surviving does not
imply working.

It also carries the correction that "Don't keep activities" -- the
report's own suggested lever -- does not work on this device at all,
so nobody spends an afternoon on it. A configuration change the
manifest does not declare does, in one line.

And it stops recommending `wails3 task android:run:device`, which
uninstalls the released app and the user's library to install a build
with a different id (#159), in favour of the manual sequence.

NOTES.md carries the measurements, dated: 8 of 8 recreations fatal
before, 5 of 5 survived after, and the note that runs where no
recreation happened are inconclusive rather than passes -- a harness
that does not check for the second bridge init reports those as green
and reads as flakiness.

Refs #52, #159, #160
2026-08-20 13:04:18 -04:00
logan d714bd7090 fix(android): keep the Go app alive when the activity is destroyed
onDestroy called bridge.shutdown(), which is the natural reading of the
callback and is wrong for this app twice over. Android destroys and
recreates an activity without restarting the process, and when the user
really does leave, this app's reason for existing in the background is
that a song is playing -- which is what the mediaPlayback foreground
service holds the process alive for. Either way, tearing the Go side
down here stops the music.

It was harmless only by accident, and that is worth writing down:
nativeShutdown calls App.Quit(), whose Android destroy() is an empty
method, and Run()'s deferred shutdownServices() cannot fire because
platformRun is `select{}` and never returns. So **no ServiceShutdown has
ever run on Android**. Removing the call changes nothing today; it stops
the day someone implements destroy() from silently killing playback on a
rotation. There is no callback for the process going away -- Android
just kills it -- so durability here is the persist writers, which submit
on every mutation rather than at exit.

WailsBridge.initialize gains the comment for the trap next to it.
Making `initialized` static is the obvious reading of "initialise once
per process" and is wrong: nativeInit also stores the global JNI
reference to *this* bridge, so skipping it leaves Go executing
JavaScript against the destroyed activity's WebView, and the app opens,
renders, and never receives another backend event. The half that must
not repeat is latched in Go instead -- which is also where the damage
was, and the only place that can see it.

Refs #52
2026-08-20 13:04:04 -04:00
logan d64b069053 fix(android): run main() once per process, not once per activity
Wails' Android entry point is `nativeInit`, which `MainActivity.onCreate`
calls, and it runs `go mainFunc()` every time. Android destroys and
recreates an activity **without restarting the process** -- a
configuration change the manifest does not declare, memory pressure, or
every background under "Don't keep activities" -- so main() ran again on
a live app.

Every path out of that is fatal. `application.New` returns the existing
app rather than building a second one, `app.Run()` then refuses because
`a.starting` is still true behind Android's `select{}`, and the
`os.Exit(1)` under that error takes the **first**, healthy app down with
it: its database, its queue, and the audio a mediaPlayback foreground
service is holding the process alive to play. ActivityManager restarts
the app, which is the report.

Measured on a Light Phone III (Android 14, arm64): conditional on the
activity actually being recreated, the process died 8 times out of 8.
The runs that "passed" were runs where no recreation happened, which is
the whole of the report's "sometimes". After this, 5/5 recreations
survive on one pid, plus six background/foreground cycles.

It never left evidence because os.Exit is not a crash: no tombstone, no
AndroidRuntime stack, nothing in `logcat -b crash`, and the slog line
naming the error went to /dev/null with the rest of fd 1.

The latch is first in main() because everything below it -- above all
NewYellowJacketApp, which opens the SQLite database -- is work that must
not happen twice in one process. It is inert off Android.

Returning early is not a degraded mode: nativeInit has already
re-pointed the JNI reference at the new bridge, so the recreated
WebView talks to the app that is still running, with its queue and
playback position intact. Verified by hooking dispatchWailsEvent on the
recreated page: IndexStatusChanged, JobsChanged, android:storageAccess.

No tier here runs main() on Android, so the guard is a source sweep, in
the spirit of TestNoDirectRuntimeEmits. The failure it exists for is not
the latch being deleted -- that is loud -- but a line creeping in above
it.

Closes #52
2026-08-20 13:03:51 -04:00
logan 5490b2423e Merge pull request 'Put the phone Now Playing button above the artwork' (#158) from fix/150-expand-button-under-the-art into main
CI / check (push) Successful in 2m29s
CI / e2e (push) Successful in 8m3s
The button tied with the cover placeholder on paint order and lost, so
it did not work for any track without artwork.

Closes #150
2026-08-20 05:47:46 +00:00
logan ffc9490a32 fix(player): put the phone's Now Playing button above the artwork
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 8m1s
`.expand` is the phone's only route into the full-screen now-playing
view. It is absolutely positioned with `z-index: auto` over
`.cover-art`, which is a *later* sibling with the same z-index, so the
two tie on paint order and the later one wins. An `<img>` costs nothing
there; a track with no artwork renders a placeholder `wa-icon`, which
takes every click aimed at the button underneath it.

So the control did not work whenever the current song had no cover, on
the one platform that has no other way in. Nothing to do with the
fixture: any library has untagged files.

Measured at 390px with elementFromPoint at the button's centre — the
icon with a placeholder, the button with an image, and the button
either way with the z-index. Chosen over `pointer-events: none` on the
art, which would take the cover preview's mouseenter with it, and over
reordering the DOM, which leaves the same tie to be won by the same
accident in the other direction.

This was filed as an e2e flake, and the diagnosis was wrong: it failed
on both engines three times across two branches that could not have
caused it, and passed on re-run each time, because the spec starts the
*first* row of the track list and which track that is depends on the
order the scan inserted rows — the same root cause as #156. The new
spec picks a track *for* having no artwork, and asserts the placeholder
is rendered rather than assuming it, so it cannot quietly go back to
measuring the easy case.

Two things it has to get right, both already documented traps: the
track must be the 90-second one, since a 2-second one finishes before
the assertions run; and `library.Track.CoverArt` is empty for all 31
fixture rows, so "the first track with no cover art" selects nothing in
particular and picked a short one.

Verified by mutation: without the z-index the new spec fails on the
click in 30s, and the pre-existing one beside it passes, which is
exactly how this survived.

Closes #150
2026-08-20 01:33:35 -04:00
logan dc6625d33a Merge pull request 'Centre the transport, and show the volume inline' (#155) from feat/42-inline-volume-and-centred-transport into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 8m21s
Three columns whose outer two match, so the middle is centred; the
volume moves into the bar as a slider, with the popup as a setting.

Closes #23
Closes #42
2026-08-20 05:22:03 +00:00
logan dc8db159f9 feat(player): centre the transport and show the volume inline
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m18s
Two issues over one bar, because they are one relayout. #42's own
findings say so: giving wa-slider a label grows it 6px to 14px and
moves the transport, which is #23's subject, so doing them in sequence
means measuring the bar twice and throwing the first set away.

The bar was `320px 1fr auto`, so the transport sat in the middle of
what the metadata and the queue button did not use — its centre was
~140px right of the window's at every width. The outer two tracks are
the same expression now, so the middle is centred by construction.

The side width is the metadata's, capped at a quarter of the bar, and
the cap was measured as a regression before it was a decision:
reserving the full `--now-playing-width` on both sides is perfectly
centred and takes the seek bar's track from 257px to 61px at 800px, and
to 0 at 200% text. The control you drag was paying for the symmetry.
With the cap it is 246, which is parity. It is a `min()` rather than a
breakpoint because that variable is user state — the metadata has a
drag handle — and tying both sides to it is also what keeps dragging
meaningful; a plain `1fr … 1fr` centres just as well and silently makes
the handle a no-op.

The volume moved out of `audio-player` into the bar because the
transport column has to hold the transport and nothing else, and it
joins the queue button in one cell rather than a second column, since
the centring compares columns.

It is a slider by default and a popup by setting. The stored flag names
the *popup*, which is this config's polarity rule — the zero value has
to be the intended answer, so an existing config.toml gets the new
default with no migration. Inline, the icon is the mute toggle and is
named after that action rather than the state, because with the slider
beside it there is nothing to disclose; the component tier now covers
both presentations rather than whichever is default.

Three nested rules in this block began with a bare element selector,
which Chrome 120 relaxed and the phone's Chrome 113 **silently drops** —
including the ellipsis on the bar's own title and artist, which has
therefore never truncated on the device. They are `&`-prefixed now.
Filed as #154 for the class and for a check.

`bottom-bar.spec.ts` pins both halves separately on purpose: an
uncapped build is perfectly centred and fails only the seek-bar width,
so a spec asserting centring alone would have passed the regression
above. Both were verified by mutation.

Closes #23
Closes #42
2026-08-20 00:40:52 -04:00
logan 86e7444603 Merge pull request (#157) from fix/156-queue-selection-fixture-order into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 7m53s
The spec asked for the first few tracks and needed one with an album.

Closes #156
2026-08-20 04:35:09 +00:00
logan 2365806d18 fix(e2e): ask the fixture for a track that can navigate
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m4s
`queue-selection`'s name-click test failed on main on both engines,
having passed in its own PR and in two consecutive local suite runs. I
added it in #152; this is my defect and it had main red.

It staged a queue from the first few rows of `GetTracks(0)` and clicked
a track *name*, which `explore-link` routes to that track's **album**
page. Four tracks in the fixture library have no album — `01 Tone A`,
`02 Tone B`, `Title Only`, `no-tags-at-all` — and a name with nothing
to route to renders as plain text rather than as a link.

Which tracks arrive first is `audio_files.id` order, which is the order
the *scan* inserted them, which depends on concurrency and directory
traversal. Locally the first eight are all from two proper albums; CI
rebuilds its seed with a real scan and got a different eight. The
fixture had a requirement it did not state, so the queue now asks for
tracks that have an album.

A loose locator is what turned that into a mystery rather than a
message. The row was located with `.explore-link` and `first()`, and a
row has two — title and artist. With the title as plain text, `first()`
silently resolved to the *artist* link, so the click went somewhere
real and the assertion was about a destination the test had never
exercised. It names `.track-title .explore-link` now.

Reproduced before fixing, by staging the CI condition deliberately: a
no-album track at row 2 fails the test in 30s on this machine, and the
filtered fixture passes in 752ms.

The Direction's sweep found one other spec slicing `GetTracks` —
`queue-reorder`, which asserts on order alone and needs no property of
the tracks it gets, so it is left as it is.

Closes #156
2026-08-20 00:23:31 -04:00
logan 7d348f243a Merge pull request (#153) from fix/151-fuse-the-scroll-guard-and-the-write into main
CI / check (push) Successful in 2m35s
CI / e2e (push) Failing after 8m41s
Removes the window between the scroll guard and the write it guards.

Closes #151
2026-08-20 03:58:04 +00:00
logan ddd04623f7 test(e2e): fuse the scroll guard and the write it guards
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m3s
`album-dropdown`'s "can be scrolled" failed twice over two sessions with
`Expected 80, Received 10`, both times on a branch that could not have
caused it. #133 strengthened the guard from "scrollable at all" to "has
the range this assertion needs", which was necessary and cannot be
sufficient: the guard and the write are separate round trips, so the
page re-lays-out between them.

Measured every frame across the resize, three runs: the range goes 0 →
**88** at 1ms → 330 settled by 8-14ms. 88 satisfies a guard asking for
80 while the grid is still a pass from done, so the guard is capable of
passing on a layout that is about to move. Under full-suite load the
transient is worse — the observed failures read 10 — which is why this
shows up on the second run of a suite and not in ten consecutive runs
of the file alone (0/10 before the change and after it; isolation is
not where this lives).

So the probe sets `scrollTop` and returns what it reads back, in one
page-side call, and the poll retries that. The assertion is now about
what the grid did rather than about what it was ready to do, and there
is no window between deciding and doing for anything to happen in.

#133's own last line asked for the other viewport-shrinking specs to be
swept for the same shape. One had it: `layout-overflow`'s sidebar probe
already fused its scroll and its measurement into one evaluate but ran
it once, so it read whatever the sidebar happened to be doing after the
resize. It is polled now — safe to repeat, because scrolling to the
bottom twice is scrolling to the bottom.

Closes #151
2026-08-19 23:37:41 -04:00
logan 9ad1477b1e Merge pull request 'Pin the queue panel'''s mouse model' (#152) from fix/43-queue-panel-selection into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m0s
Pins the queue panel's single-click select, ctrl/shift extend and
double-click play, none of which were covered in either tier.

Closes #43
2026-08-20 03:14:30 +00:00
logan 70ab3ddf94 docs: record two measurements from the queue selection work
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m41s
CI / e2e (pull_request) Successful in 8m1s
The first is a second instance of a rule CLAUDE.md already states, with
numbers: a virtualized list can be repainting for a reason you are about
to delete, and here there are two such reasons — so removing either
alone changes nothing observable, and removing both leaves the highlight
seconds late rather than absent. That is the shape a poll cannot see,
which is the general lesson worth keeping.

The second is the hit-scan, because it stopped a wrong fix: the queue
panel is 12% link and the track list 21%, which is the opposite of the
assumption the fix was being built on.
2026-08-19 23:00:03 -04:00
logan 4f7529c315 test(queue): pin the panel's mouse model, and bound the highlight
Single click selects, ctrl and shift extend, double click plays from
that row — all four already worked, and nothing in either tier pinned
any of them, which is why the report could be made and could not be
settled. `queue-reorder.spec.ts` covers the keyboard and
`queue-overlay.spec.ts` the panel's mode; the pointer path had no
coverage at all, so "selection is broken here" and "selection is fine
here" were equally consistent with a green suite.

Measured with real mouse events rather than dispatched ones, because a
synthetic click aimed at the row bypasses the only thing that could be
swallowing it: click row 1 selects 1, ctrl+click 4 gives 1 and 4,
shift+click 7 extends to 1,4,5,6,7, a plain click collapses to one, and
a double click on row 3 leaves the backend playing row 3.

The three candidates the issue lists are all answered. The repaint was
already correct, and already correct on the day the issue was filed.
`resolveTrackIndexFromEvent` reads data-index, and DOM order matches
data order. A row control does swallow the click — `explore-link` stops
propagation on purpose, so a click on a name navigates and selects
nothing — but a hit-scan across a row makes the queue 12% link against
the track list's 21%, so the panel called broken is *less* covered by
links than the list called correct. That measurement killed the fix
this started out as.

Two traps are written into the spec because both faked a defect while
measuring. Fixture tracks are 2 seconds, so "double click row 3" read a
moment later reports whatever auto-advance moved on to — recorded twice
as an off-by-one that is not one, which is what `LONG_TRACK` exists
for. And the selection assertions are bounded at 500ms rather than
polled with the default 5s: `queue-panel` repaints two ways, the
explicit `requestUpdate()` and a per-render `keyFunction` arrow, and
with *both* removed the highlight still arrives — at 134ms, 3.9s and
5.8s against 5-17ms healthy. Four seconds is indistinguishable from
broken to a user and invisible to a generous poll.

Mutation-tested rather than trusted: `playAtIndex(index + 1)` fails both
double-click tests, treating every click as ctrl+click fails both
selection tests, and removing both repaint mechanisms fails all three
selection tests — the last only because of the bound.

Closes #43
2026-08-19 22:59:55 -04:00
logan bb21072386 Merge pull request 'The top bar decides what it can afford to show' (#149) from fix/143-top-bar-fits-its-window into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 7m59s
Fits the top bar to its window by measuring it, at every supported
width and with work in flight.

Closes #143
2026-08-20 02:11:54 +00:00
logan ead1354e4d docs: record how the top bar decides what to drop
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Successful in 8m38s
The shell section already states the three size bands and the promise
that no action is unreachable at any of them; how the header chooses
what to give up belongs beside them, because the promise is what
decides it.

Two measured facts go to NOTES.md rather than here. `scrollWidth`
counts a box's left padding and not its right, so the obvious fit
predicate under-reports by a gutter and passed on a bar with a control
jammed against the window edge. And the overflow is 11px idle and 262px
while working, which is why the issue was filed twice with different
numbers — a seeded app that has finished scanning is idle by the time
you resize it.

Closes #143
2026-08-19 21:35:12 -04:00
logan ae85df0dad fix(shell): give the top bar a measured fit at every width
The bar was 611px inside a 600px viewport at the bottom of the Compact
band, and 862px while a scan with a real library's title ran, because
`job-indicator` is `hidden` when idle and 235px wide when it is not.
`body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a
shell #24 promised would not need one — and the band is 600 to 899 with
work in flight, not the 600 to 610 the idle measurement suggested.

`services/top-bar-fit.ts` is `page-header`'s treatment one bar up: a
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits. Measured rather than breakpointed
because three of the five children are as wide as their content — the
library filter by the longest library name, the indicator by the
running job's title, the search box by its view-scoped placeholder — so
any width picked is right for one library, one job and one view.

What yields is decided by #24's own sentence, which rules out the two
cheapest candidates in the Direction. Hiding the library filter takes
away an action, since it is the only control in the app that selects a
library (filed as #148, which is the phone already doing it), and
collapsing search to an icon is #57's, which is blocked behind #62. So
the wordmark yields first — a brand the window title bar repeats, and
visually-hidden rather than `display: none` because that h1 is the
document's heading — and then the indicator's label, leaving the ring,
which the component already does below 600px and whose live region
announces the state either way.

"Fits" is the children against the content box, not `scrollWidth`
against `clientWidth`: `scrollWidth` counts the left padding and not
the right, so the first version read 700/700 with the indicator sitting
in the whole right gutter. And the bar does not resize when a job
starts, which is the case this is for, so every child is observed too.

Pinned before it was fixed, as the issue asks. On the unfixed build the
new spec fails at 600 idle and at 600, 800 and 900 with a job, and
passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600
and failed there. That spec asserts on the *shell*, so it was green
throughout this defect — the per-child measurement is #69's lesson, and
it is what caught the gutter case above.

Closes #143
2026-08-19 21:35:03 -04:00
logan 6e7e349e63 Merge pull request 'Fold the Jobs tab into the places the work is started' (#147) from feat/27-jobs-into-settings into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 7m25s
2026-08-20 01:02:41 +00:00
logan f9ba9a87d7 docs: note that CI's two engines share one app
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m32s
Which is why a shared-selector fault can be green on chromium and red
on webkit in the same run, and how to reproduce it locally.
2026-08-19 20:44:16 -04:00
logan e4efec6f0c fix(e2e): the third spec that located a disclosure by class
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Canceled after 8s
CI / e2e (pull_request) Canceled after 0s
`config-section .header` is ambiguous once a section holds a job, and
`failure-voice.spec.ts` was the one I did not grep for. It passed on
chromium and failed on webkit in the same CI run, which is the tell:
the two engines share one app, so the second one runs with a finished
scan the first one left behind.

The NOTES entry already says a class name is not a selector's contract;
this is the same fix, by role and name.
2026-08-19 20:44:02 -04:00
logan 99f355b2fc docs: record where background jobs went, and two traps
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m37s
CI / e2e (pull_request) Failing after 7m35s
The retired-destination note sits beside #25's storage paragraph
because it is the exception to it: an absent key is free, a value is
not.

`config-section .header` resolving to two elements is in NOTES because
it cannot be reproduced by opening Settings and looking -- the drawer
only exists once a job does.
2026-08-19 20:27:55 -04:00
logan c79d4d47a3 test: cover jobs in Settings, and unpick two shared selectors
The spec worth having is not that the tab is gone -- that is one line
of a table -- but that nothing became unreachable when it went. #24
promises that no action is ever unreachable at any supported size, and
deleting a destination is exactly the change that quietly breaks it.

Two existing selectors had to give. `config-section .header` is
ambiguous the moment a section holds a job, because `job-details-drawer`
carries that class too -- so `settings-reach.spec.ts` locates a
disclosure by role and name instead. And `page-header`'s and
`offline-icons`'s view lists lose an entry each.

Closes #27
2026-08-19 20:27:48 -04:00
logan 8efed2dd2b refactor(shell): retire the Jobs destination
Nothing it carried is gone -- the two commits before this put all of it
somewhere the work is already being done.

A retired destination is the one shape #25's storage decision does not
make free. A visibility entry is a map key and an unknown key is
dropped on load; a launch page is a *value*, and an unknown one fails
validation -- which on the load path means the app refuses to start for
whoever had Jobs selected. `RetiredViews` is that list, read by
`ApplyDefaults`, which treats a retired name as a zero value. An
unknown-but-not-retired name still errors, because that is a typo and
saying so is the useful answer.
2026-08-19 20:27:40 -04:00
logan 12af6ec1f7 feat(settings): scan from Settings, and watch every job in place
Scanning goes back where libraries are managed -- the Jobs tab's own
comment said the per-library controls had been taken out of Settings to
build it. "Scan All" and "Full Rescan" join "Add Library", "Scan now"
joins the per-library overflow menu beside Rename and Remove, and a
library being scanned says so where its track count goes.

Each surface then gets the job rows for its own kind: scans under
Libraries, index and enrichment under Search Index, downloads under the
download clients, and the autotag apply in the Autotag view -- where
stopping a run matters most, since applying rewrites tags on disk and
that view had no cancel at all.

The autotag panel shares the header's grid row through a wrapper rather
than taking a third row: it is display:none while nothing is applying,
which is nearly always, and a grid row would still spend the
container's gap on it.

The Launch Page select is derived from `VIEW_META` instead of listing
its ten options, since removing a destination is exactly the change
that leaves two hand-written copies disagreeing.
2026-08-19 20:27:33 -04:00
logan f3d1ae1c8c feat(jobs): show background work where the work is started
Reading the app before moving anything turned up that four of the five
job kinds already have a home showing their work: Settings → Search
Index draws per-tier index progress, `downloads-view` draws every
download's lifecycle state, `autotag-view` draws its own apply ring,
and only `library-scan` had nowhere but the Jobs tab. What none of the
four had is the *generic* affordances — pause, cancel, Details, the
log, and a finished job you can dismiss.

So this is a panel embedded beside each of them rather than one
"Background jobs" section in Settings, which would have been the tab
again under another name.

Three rules in it. The controls are `applyJobControl`, not a
reimplementation, which is what keeps the index build's "you will
discard hours of downloading" confirmation alive across the move. A
panel with nothing to say is `hidden` rather than empty, host margin
included, because an idle panel in four places is four pieces of
furniture describing an absence. And there is no "Clear finished":
`ClearFinishedJobs` is global, so a Clear under Libraries would discard
the index build's history too.

`JobKind` also gains `download`, which the backend has had all along.
2026-08-19 20:27:22 -04:00
logan 5af545e38d Merge pull request 'Configurable sidebar destinations, Downloads gated on a client, Autotag off by default' (#145) from feat/25-configurable-sidebar-tabs into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 7m1s
2026-08-19 23:46:23 +00:00
logan 9da3967dd9 docs: record that destinations are configuration
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m5s
Beside the three navigation paragraphs, since it is the fourth thing
the shell states about where the user can go.

Two notes are measured facts rather than design: a default expressed as
an *absent key* survives an existing seed, where one expressed as a zero
value does not; and a spec can no longer assume a destination has a nav
item.
2026-08-19 19:34:00 -04:00
logan c8d94a8203 test(e2e): cover configurable destinations; stop assuming a nav item
The assertions are about the navigation, not about the setting: "the
config was saved" is the plumbing, and #69 and #72 both shipped green
under specs that measured exactly that.

Four existing specs reached a view by clicking its nav item, which since
this change is not guaranteed to exist -- Autotag is hidden by default
and Downloads is absent without a download client -- so they timed out
waiting for a locator that will never resolve. `navigateTo` dispatches
the app's own `navigate` event, which is what every nav item, card and
detail view dispatches, so it is the mechanism rather than a test-only
door. Click the item when the nav is the subject.

Closes #25
2026-08-19 19:33:56 -04:00
logan 43d78a731a feat(shell): draw only the destinations the user kept
The navigation reads the resolved map from the backend rather than
holding a copy of the defaults, which would be the copy that shipped in
the binary rather than the one being edited.

Hiding takes away the nav item and nothing else: `navigate` still
resolves a hidden view, which detail views and the launch page depend
on. No special case was needed for the highlight, because #72 moved
that onto `active-view-store` -- the sidebar asks `isActive(id)` per
*rendered* item, so a hidden view lights nothing exactly as a detail
view does.

Downloads is gated at the nav on `downloadStore.available` rather than
in the config, so switching it on in Settings still means what it says
once a client exists, and the tab appears without a restart. `available`
is false until the providers have loaded, which makes the item appear on
a fresh launch rather than appearing and then vanishing.

The tab bar honours the toggles too, and the reason is local rather than
a general rule about phones: "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's subset; this only removes
from it, and "More" is never filtered.

`services/view-meta.ts` is the destination list, on `shortcut-meta.ts`'s
pattern, because Settings is now a second reader of the same labels in
the same order.

Two existing sidebar tests had to say which world they describe: eleven
destinations now assumes a configured download client.
2026-08-19 19:33:44 -04:00
logan a3926704cc feat(config): make the shell's destinations configurable
The sidebar's eleven entries are more than most libraries need, and
Autotag rewrites tags on disk, which is not what a fresh install should
be one click from.

Stored as a map keyed by view id, where an absent key means that view's
own default. A `HiddenViews []string` cannot express "Autotag off by
default" -- its zero value is *hide nothing* -- and a boolean per view
turns a view that later stops existing into stored garbage. With a map,
an unknown key is dropped on load, a view added later gets its own
default, and no install needs migrating in either direction. Same
polarity as AllowMeteredCatalogDownload: the zero value is the intended
answer.

`Views` is also what DefaultPage now validates against, so which views
exist and which may be the launch page are one list rather than two.

Two states the user could not get out of are refused rather than
allowed: Settings is never hideable, and the launch page is not
hideable while it is the launch page. Both refuse in the *config*, not
in the UI, because `config.toml` is hand-editable. On load the launch
page is instead un-hidden -- there is nobody to tell, and the honest
reading of "my launch page is Autotag" is that this user wants Autotag,
not that their launch page should be silently reset.
2026-08-19 19:33:32 -04:00
logan ea53d4f15b Merge pull request 'Global back and forward, and the launch entry that made Back a lie' (#144) from feat/6-global-back-forward into main
CI / check (push) Successful in 2m23s
CI / e2e (push) Successful in 7m3s
The stack was always global; the affordance was not. Adds the control, and fixes the second launch navigation that left a fresh session one entry deep.

Closes #6
Closes #142
2026-08-19 22:27:39 +00:00
logan ea16e07c46 docs: record the back/forward chrome and the two launch navigations
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m4s
Both belong beside the rules that already keep the history stack
honest: the depth counting, because forward is the case one counter
cannot express, and the second launch navigation, because it is what
silently defeated the first rule on the list.
2026-08-19 18:09:10 -04:00
logan 4f47c85208 test(e2e): cover global back/forward, including the launch entry
Five journeys the control has to get right: nothing offered at the root
in either direction, walking both ways with the availability changing
as it goes, reaching the detail view a tab click left behind (which is
the report), the forward list being dropped when the user navigates
from the middle, and the control standing down below 900px.

The first fails on the build before the launch entry was fixed --
enabled, and doing nothing when pressed. It is the assertion that pins
that defect, which otherwise has no visible symptom on desktop at all.
2026-08-19 18:09:10 -04:00
logan 603728a3fb fix(shell): let the landing page replace the launch entry
The app navigates twice on startup and both are deliberate: the eager
`navigate -> home` that paints without waiting for the backend, and the
configured page `GetDefaultPage()` resolves to a moment later. Only the
first replaced the launch entry, so the second stacked on it and a
fresh session was already one entry deep before the user had touched
anything.

The first back press therefore replayed home over home. On desktop that
was invisible until this branch drew a Back button, which rendered live
at the root and did nothing; on Android `webView.canGoBack()` was true,
so the press that should have exited the app was swallowed -- the exact
fault the replace-the-launch-entry rule exists to prevent, defeated by
there being two launch navigations rather than one.

Guarded on still being at index 0 rather than on a flag: that call is
asynchronous and the user can navigate while it is in flight, so past
the root this is an ordinary navigation and a slow answer cannot
overwrite an entry they made.

Closes #142
2026-08-19 18:09:10 -04:00
logan 018d857746 feat(shell): global back and forward in the top bar
The history stack has been global since the Android back gesture landed
-- every navigation is an entry and `popstate` restores any of them in
either direction. What the report describes as "back is tab-scoped" is
that the only way back was a detail view's own button, which leaves the
screen with the view it belongs to: click over to Tracks and the album
you were reading is still one entry away with nothing on screen saying
so.

`<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on
Alt+Left / Alt+Right -- the browser's own combination, and clear of the
bare arrows that seek, since a binding matches on its full canonical
string.

Forward is not back negated, which is why the old `pushedEntries`
counter is gone rather than extended: `popstate` carries no direction
and fires identically both ways, so one counter decremented on every
pop reads a forward as a second back. Each entry carries its index and
the shell keeps the current one and a high-water mark, which also
survives a jump of more than one.

The buttons dispatch the events the rest of the app already dispatches
rather than calling `history` themselves -- the shell owns the guard
that stops a press at the root leaving the app, and a second caller
reaching for history is how the old `navStack` came to disagree with
the platform.

Below 900px the control stands down: the top bar is what runs out of
room first below that, and nothing becomes unreachable -- the shortcuts
are global at every width and the phone has the platform's gesture.

Closes #6
2026-08-19 18:08:55 -04:00
logan a7ac2b4a3e Merge pull request 'Publish the active view from the shell, so both navs survive the back path' (#141) from fix/72-active-view-broadcast into main
CI / check (push) Successful in 2m29s
CI / e2e (push) Successful in 6m59s
The shell knew which view was active and never said so on the back path, so both navs highlighted the view just left.

Closes #72
2026-08-19 21:10:54 +00:00
logan d347809e6e docs: record the one statement of which view is active
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 6m53s
It belongs beside the two rules that already keep the history stack and
the in-app back buttons agreeing, and for the same reason: a second
component-local idea of where the user is, is how they came to
disagree.
2026-08-19 17:00:39 -04:00
logan a5ffcc22e3 test(e2e): assert the nav highlight, not the shell's own bookkeeping
`back-navigation.spec.ts` covered exactly the journeys #72 breaks and
was green throughout it, because every assertion in it was
`data-active-view` — which the shell sets on every path including the
back one, and which was the one thing already correct. The same trap
`layout-overflow.spec.ts` set for #69: a spec named for the behaviour,
measuring the plumbing.

The assertions go here rather than in a second file, or the first would
carry on passing vacuously. They are `aria-current="page"` through
`getByRole`, which is the accessible fact — `.active` is a class and
could be restyled without breaking anything real — and the role query
resolves to whichever nav is in the accessibility tree at that
viewport, so one helper covers the sidebar and the tab bar.

Three of the four fail on the build before the fix. The fourth, the
parent staying lit while a detail view is open, passed by accident and
says so.
2026-08-19 17:00:39 -04:00
logan f18691560d fix(shell): publish the active view, so both navs follow the back path
The nav components learned where the user was from the `navigate`
CustomEvent, which only the outbound path dispatches: `popstate` calls
`handleNavigate()` directly. So a back-navigation left both of them
highlighting the view just left — desktop included, at any width, on
any back across two primary views. Opening a detail view was the same
cause wearing a different symptom: `app-sidebar` guarded on its own
item list and kept its highlight, `bottom-nav` did not and lit nothing.

It cannot be fixed by re-dispatching `navigate` — `index.ts` is that
event's document listener, so that is an infinite loop, and "please go
to X" is not the statement being made. `activeViewStore` is the shell
saying "the active view is now X", once per navigation, `popstate`
included; both navs read it through a controller and hold no
`activeView` of their own.

A store rather than an event because a component that mounts *after* a
navigation still has to know: `bottom-nav`'s drawer builds its
`app-sidebar` on open, and that copy had heard nothing at all, so the
drawer opened on Home from any page in the app.

Closes #72
2026-08-19 17:00:39 -04:00
logan c84a9069ef Merge pull request 'Collapse the page header actions that do not fit, instead of clipping them' (#136) from fix/69-page-header-action-overflow into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 6m53s
Closes #69
2026-08-19 19:29:15 +00:00
logan f967916550 fix(page-header): collapse the actions that do not fit into a menu
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m36s
CI / e2e (pull_request) Successful in 7m17s
Playlists slotted three buttons totalling 390px into a header that gets
700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px
with the queue closed, and 158 of 162 at the 800x600 enforced minimum.
On a phone none of the three could be reached at all, which is what the
Android report said. Plan 018's size matrix promises the opposite: no
action is ever unreachable at any supported size.

The header could not fix that for slotted markup, and that is a fact
about the API rather than an effort estimate — a component cannot move
another component's light-DOM children into a dropdown and keep their
behaviour, and arbitrary markup offers nothing generic to render as a
menu item. So a host passes `PageAction[]` and the header chooses the
rendering; the slot survives for markup a data list cannot express, at
the stated cost that a slotted action does not collapse.

All three hosts that slot actions migrated, which also normalises the
plain-<button>/<wa-button> split between them onto one shape the header
styles — and lets it measure a button that has already upgraded, rather
than a wa-button whose shadow DOM arrives in its own first update.

Four things in it are load-bearing:

- Every measuring pass starts from all-visible, so the collapsed set is
  a pure function of the current width and an action comes back when
  the window grows. It flips `hidden` imperatively rather than
  re-rendering between steps, or the intermediate state paints and the
  fix flashes the overflow it exists to prevent.
- "Fits" means nothing is clipped, not that the header does not
  overflow. Once the title can ellipsis it absorbs the pressure and
  scrollWidth reports a perfect fit while the heading reads "Playlis…"
  — this bug moved from the button to the title, and invisible to the
  same measurement that missed it the first time.
- New Playlist has the highest priority because it is the drop target
  and a closed menu cannot be one. `PageAction.drop` therefore carries
  the host's own handlers; the affordance is absent from the overflow
  rather than approximated there.
- The overflow trigger is a named button with aria-expanded and an
  aria-controls naming a panel that is always in the DOM, and the
  keyboard model is the shared `MenuKeyboard`.

`layout-overflow.spec.ts` passes on the broken build — it asserts the
shell needs no sideways scrolling, and clipping inside a component is
invisible to it, which is why this defect survived a spec named for it.
The new spec measures each button against its own header at four
viewports and asserts buttons plus menu account for every declared
action, without which it would pass vacuously on a build rendering none.

Closes #69
2026-08-19 15:08:57 -04:00
logan 3fa7c7734b docs: record the page-header actions rule, and complete plan 018
The `page-header` paragraph already stated "the header asks for a sort,
it does not perform one"; actions now follow the same division and it
belongs beside it — the header decides what fits, the host decides what
happens.

Plan 018 moves to completed/ because #69 was the last thing it owed:
its size matrix promised "no action is ever unreachable at any
supported size" and the residual 114/162px clip was that promise
outstanding. Its recap also corrects a claim the plan made — the queue
and the actions were not the only two things competing for the header's
width, since every child of that flex row was flex-shrink: 0 and the
actions come last.
2026-08-19 15:08:57 -04:00
logan cceeb40b16 Merge pull request 'Six quick fixes off the tracker: tooling, a latent index bug, and two touch affordances' (#139) from fix/quick-wins-batch into main
CI / check (push) Successful in 2m34s
CI / e2e (push) Successful in 6m44s
Closes #130
Closes #131
Closes #119
Closes #118
Closes #68
Closes #61
2026-08-19 19:08:46 +00:00
yonlu 2926ecd4b4 docs(notes): record that no test tier can see a hover media query
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m2s
CI / e2e (pull_request) Successful in 6m33s
Both browser tiers are blind to `(hover: hover)` gating, in different
ways and without failing: CDP media emulation does not reach ui-test's
iframe, and e2e's phone specs reach phone width with setViewportSize,
which changes no media feature but width. Written down with what does
work — a device-descriptor context — because the next person to gate an
affordance this way will otherwise re-derive it, and the tempting
conclusion from a green suite is that the gate is covered.
2026-08-19 14:08:38 -04:00
yonlu ff3c4003cb Merge branch 'fix/61-mini-player-plain-text' into fix/quick-wins-batch 2026-08-19 14:08:15 -04:00
yonlu def596a99e Merge branch 'fix/68-hover-affordances-pointer' into fix/quick-wins-batch 2026-08-19 14:08:14 -04:00
yonlu 14f78c0b57 Merge branch 'fix/118-in-library-clear' into fix/quick-wins-batch 2026-08-19 14:08:14 -04:00
yonlu 7cea238e71 Merge branch 'fix/119-dev-headless-port' into fix/quick-wins-batch 2026-08-19 14:08:13 -04:00
yonlu 4f2f1827ab Merge branch 'fix/131-codegen-check-scope' into fix/quick-wins-batch 2026-08-19 14:08:13 -04:00
yonlu e454e4074b Merge branch 'fix/130-issue-claim-user' into fix/quick-wins-batch 2026-08-19 14:08:12 -04:00
yonlu c518ac8c73 feat(now-playing): plain text instead of links in the phone mini player
CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a
bar sized for a bar they are a few characters of text, which is not a
touch target — and explore-link holds its navigation for one
double-click interval and drops it if a second click arrives, a gesture
that exists so double-clicking a row can play it and that means nothing
on touch.

Below the shell's phone breakpoint the three render as plain text. The
words are unchanged: the source line still says where the queue came
from, because dropping the link is the change and dropping the
information would be a different and worse one. The cover art already
carries the phone-only button that opens the full-screen Now Playing
view, which is where the links live.

This is in JS rather than in the stylesheet because what changes is the
content, not its appearance — no CSS rule takes a click handler off an
element. matchMedia is read in connectedCallback for the reason the
reduce-motion query beside it already is, so a test can answer it first.

Two smaller things. PHONE_QUERY moves out of track-list.ts into
utils/breakpoints.ts: it was a private const when one component needed
it, and a second reader is where a copy starts drifting from index.css.
And `phone` joins geometryKey(), because crossing the breakpoint swaps a
link for a bare string and the marquee travels a distance read from
measuring it — the words being identical either side is not the same as
the box measuring the same.

Closes #61
2026-08-19 14:08:06 -04:00
yonlu 977f624123 fix(home): gate the card play button on the device having hover
CI / check (push) Skipped
CI / e2e (push) Skipped
The play button on a home shelf's cover cards is revealed by :hover, and
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. A control appearing
because the user was reaching for a different one.

It is gated on `(hover: hover) and (pointer: fine)` rather than on width,
so it is absent on any touch device and present on a desktop with a small
window. A phone user taps the album and plays from the detail view, so
nothing replaces it.

The default outside the query is display:none, not opacity:0. An
opacity-0 button still takes taps and is still in the accessibility tree,
so leaving the reveal as the only guarded part would keep the hit area
for a control the phone can never show.

The test asserts the parsed stylesheet rather than rendering as a phone,
and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's
iframe, so matchMedia still answers `hover: hover` after it is set. The
regression worth catching is someone hoisting the rule back out of the
query as a tidy-up — a change no desktop assertion can see.

Closes #68
2026-08-19 14:08:06 -04:00
yonlu 23f3d4b3b0 fix(explore): clear in_library on a row that has no local id
CI / check (push) Skipped
CI / e2e (push) Skipped
`in_library = 1 AND local_*_id IS NULL` was a fixed point.
upsertBatch's conflict clause is `MAX(in_library, excluded.in_library)`,
so it can only ever raise the flag, and pruneStaleLocalCrossReferences —
which its own comment calls the only place a removal from the library is
reflected back into the index — was gated on the id being present. So
nothing in the app could clear such a row, ever: a permanent claim of
ownership with no local row to check it against.

The gate is now the flag *or* the id, for all three entity types. A NULL
id fails the existence test on its own, so this needs no second clause to
say what "not owned" means.

Nothing in the tree writes that shape today — collectLibraryEntities sets
both together — which is why this is worth closing rather than leaving:
the exposure is a database written by a version whose local-id columns
were populated differently, and the next writer that sets the flag
without an id, which nothing structurally prevents and which this shape
made permanent rather than merely wrong until the next scan.

The test seeds the row with raw SQL on purpose. upsertBatch writes a zero
LocalArtistID as literal 0, and 0 satisfies `IS NOT NULL`, so the old
gate already caught that shape — a fixture built through the upsert
cannot reproduce this at all. NULL is what the artifact importer and any
older writer leave behind, the columns being nullable with no default.
Reverted against the old gate, it fails on all three types.

Closes #118
2026-08-19 14:08:06 -04:00
yonlu 8d46c4abb7 fix(scripts): refuse to start dev-headless on a port somebody else holds
CI / check (push) Skipped
CI / e2e (push) Skipped
dev-headless.sh checked the PID in *this* worktree's .dev/app.pid and
nothing else, so an app orphaned by a deleted worktree went on listening
with nothing left to stop it — `make dev-stop` only kills the pid it
wrote. The new app then started, failed to bind, exited, and every
subsequent curl and playwright-cli call went to the other process: the
harness reported facts about an app nobody asked for.

That fails a long way from its cause. It presented as "no such table:
libraries" against a *freshly created* YJ_HOME, which reads exactly like
applySchema or staleshape.go having gone wrong, with a zero-byte app.log
beside it saying nothing.

The startup wait cannot catch this, because its health check is satisfied
by any app on the port — which is precisely the failure — so the check is
before the launch and refuses rather than warns. It names the holder's
pid, cmdline and /proc/<pid>/cwd, which is what identifies the checkout
and says "(deleted)" for the case this exists for. It does not suggest
`make dev-stop`: the PID-file check has already passed, so by
construction dev-stop does not know about this process and would report
success while changing nothing. --port already covers the legitimate
second-app case.

The second, cheaper guard the report asks for goes in after the wait:
"the port answered" is not "the app we started answered", so a dead
APP_PID at that point is now an error with the log tail rather than a
success message about somebody else's process.

Closes #119
2026-08-19 14:08:05 -04:00
yonlu f714fe513d fix(scripts): report only what generation changed, not the worktree
CI / check (push) Skipped
CI / e2e (push) Skipped
The codegen-check hook was `go generate` followed by a bare
`git diff --name-only`, which is the whole unstaged worktree rather than
the generators' output. So a commit whose staged changes were fine failed
whenever anything unrelated sat unstaged — notes, a plan document, the
next commit's files — reporting "Generated code is out of date" and then
a diffstat of files no generator has ever written. `make generate` fixed
nothing, because nothing was stale, so the message sent you looking for a
codegen problem that did not exist. Splitting one piece of work into
several commits is exactly the shape that triggers it.

The tree is snapshotted either side of `go generate` and only what moved
across it is reported. That is deliberately a snapshot rather than the
list of generated paths the issue offers as the other option: a fourth
generator is one //go:generate line away, and a path list is a second
place to remember it.

Two things it has to get right. The comparison is a *symmetric*
difference, because generation can push a file into the unstaged set or
pull it out of one — a hand-edited generated file that the generator puts
back is stale generated code just as much as a source change that
outdates it, and comparing one direction reports it as current. And the
snapshot is content, not names, or a generated file that was already
dirty and is then rewritten further keeps its name on both sides and
slips through.

Closes #131
2026-08-19 14:08:05 -04:00
yonlu 087c69ac8d fix(scripts): let issue.sh claim work on a write:issue-only token
CI / e2e (push) Skipped
CI / check (push) Skipped
`claim` is the one step the workflow requires before the first edit, and
it failed outright on a token scoped to the work it does: `me()` calls
`GET /user` purely to name the assignee, and that endpoint needs
read:user. So the documented process was blocked by its own tooling, and
the fallback was to do the assignment, the label and the comment by hand
— which is the half-made claim `claim` exists to prevent.

GITEA_USER short-circuits the lookup, so least privilege is enough. The
lookup stays as the fallback because it is right when the scope is there
and needs no setup. Failure is now actionable and says both remedies,
and it still happens before any of the three halves are mutated.

Closes #130
2026-08-19 14:08:05 -04:00
logan bb7dde1963 Merge pull request 'A CI-only change is ci:, not fix(ci):' (#112) from docs/ci-commit-type into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m31s
2026-08-19 16:23:20 +00:00
yonlu 446380e3a9 docs: a CI-only change is ci:, not fix(ci):
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m41s
CI / check (pull_request) Successful in 2m28s
The commit-analyzer reads the type and ignores the scope, so `fix` is a
patch whatever sits in the brackets. Two commits touching nothing but
.gitea/workflows/unclaim.yml were written `fix(ci):` and cut v0.2.1 and
v0.2.2 -- real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change.

CLAUDE.md already warned that a mistyped feat ships a minor version.
That was not enough, because this was not a mistyped type: `fix` was
chosen deliberately, in the belief that the (ci) scope qualified it.

The version bump is the small half, which is why this gets a paragraph
rather than a clause. A merge to main starts two workflows; if
release.yml then pushes a tag, that tag push starts four more --
arch-package, homebrew-formula, android-apk and desktop-assets -- on a
runner with capacity 1, where the APK build alone is tens of minutes
and publishes a signed artifact to a public registry. So a mistyped
type is six workflow runs, not an odd-looking changelog.

`make release-dry` answers this before the merge instead of after, and
is cheaper than any one of those runs.

The two releases are staying: they are already published, and a version
that vanishes is worse for whoever pulled it than one that turns out to
be empty.

Closes #111
2026-08-19 16:03:38 +00:00
logan e07f248cc8 Merge pull request 'Wait for the scroll range the assertion needs' (#134) from fix/133-album-dropdown-scroll-race into main
CI / e2e (push) Successful in 6m39s
CI / check (push) Successful in 2m33s
2026-08-19 16:03:17 +00:00
logan 90ac6e0825 test(e2e): wait for the scroll range the assertion needs
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 6m22s
The guard polled for `scrollHeight > clientHeight + 40` and the next
line asserted the container could be scrolled to 80, so any range in
41-79 satisfied the precondition and could not satisfy the assertion.
The grid passes through exactly that while it settles, because it
recomputes its columns after a viewport change rather than during it,
so the test read a clamped scrollTop and reported 10 against 80.

It failed CI on a pull request that changes one paragraph of CLAUDE.md
and nothing else, while WebKit passed in the same run. Reproduced
locally: 0 failures in 6 runs before #132, 2 in 9 after, 0 in 10 with
this change.

#132 is what made it reachable rather than what broke it. The queue
panel's mode is measured rather than media-queried, so a viewport
change at this width costs one more layout pass, and cover-grid settles
after it instead of before. The settled range is 330 and stable, the
main panel is 700px, and the panel is correctly display:none while
closed — there is no user-visible defect, only a wider window for a
race the spec already had.

A threshold below the value its caller depends on is not a guard, so
the target is one constant that both the guard and the assertion read.

Closes #133
2026-08-19 11:50:46 -04:00
logan 4e3c953acf Merge pull request 'Decide the supported sizes, and stop the queue taking the page's width' (#132) from feat/24-supported-sizes-queue-model into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 6m20s
2026-08-19 15:22:39 +00:00
logan ede183d026 test(shell): check 900x600, which is narrower than the minimum
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 6m42s
The sidebar collapses to icons *below* 900, so the main panel is 843px
at 899 and 700px at 900: the narrowest content area any desktop width
produces is at the top of the Compact band, not at the enforced floor.
A viewport list that stopped at "the minimum" was missing its own worst
case.

MinWidth's comment loses both reasons it used to give, because neither
mechanism can happen any more — the subtitle is display:none from 899
down, and the sidebar host is overflow-y:auto (at 600x460 its
scrollHeight is 434 against a 332px client, and Settings is reachable
after scrolling). The value does not change: 800x600 is where desktop
chrome stops being comfortable, not where the app breaks, and below
600 the phone layout takes over. A floor defended by two expired
mechanisms is a number nobody can argue with, which is worse than
either answer.

Closes #24
2026-08-19 10:54:52 -04:00
logan 481c9dca65 docs: record the size bands and what the queue model cost to find
CLAUDE.md gains the three bands as a promise (Phone <600, Compact
600-899, Desktop >=900, and "no action is ever unreachable at any
supported size"), the computed queue rule and why it cannot be a media
query, and the correction that 900 — not the 800x600 minimum — is the
worst desktop width.

NOTES.md gets the measurements, including two things worth more than
the fix. My first probe for the sidebar's scroller searched
shadowRoot.querySelectorAll('*') and reported "no scroller, items are
unreachable", which reads exactly like a live Settings-unreachable bug;
the scroller is the host, and a host is not inside its own shadow root.
And the plan's first draft claimed the overlay "removes the desktop
half of #69", which the screenshot disproved: open and closed are now
identical at 900x600, so the queue's contribution is gone, but the
header's own overflow remains and is still a live defect.

Refs #24
2026-08-19 10:54:52 -04:00
logan 4025106234 fix(queue): overlay the content instead of taking its width
The panel is flex-shrink: 0 in the flow of .content-area, so an open
queue was paid for by the main panel rather than covering it. Measured
on Playlists: 379px of content left at 900x600 with all three of the
page header's actions clipped, 69px at 390px, and 0px at 320px — where
the content was not degraded but gone.

It goes to an overlay with a scrim when the content cannot spare the
width, and the rule is computed rather than breakpointed:
`available - panelWidth < 480`, where available is .content-area's
width and so already accounts for the sidebar's collapse at 900. A
media query cannot express this, which is the reason for the property:
the panel is drag-resizable between 200 and 500px and persisted, so a
viewport breakpoint silently assumes the default 320 and is wrong by up
to 180px for a user who widened it — in the direction that hurts, since
a wider queue is exactly when the content can least afford it.

480 is a judgement and the comment says so: there is no cliff to derive
it from (the track list rescales continuously, 213px to 124px columns
with no row overflow), so it is anchored to keep the default 1100px
window inline while putting every measured-broken case on the overlay
side.

The overlay is a presentation and not a fork — #55 asks for one
component with two mount points — so the roving tab stop, Alt+Arrow
reorder, drag reorder and selection semantics are untouched. Escape
closes it and returns focus, attached only while the overlay is up: it
is a dismissal rather than a shortcut, which is why it is not a
panel-scoped binding. The scrim covers the content area only, not the
sidebar or the transport, because the queue is not modal.

Refs #24
2026-08-19 10:54:52 -04:00
logan a3134f997f docs(planning): decide the supported sizes and the queue panel's model
#24 asks for a design pass, and #73 hangs the rest of Phase 2 off the
answer, so the decision is written down before any CSS moves.

Measured against the running app, and five things are not in the issue:
the Playlists header clips at 800x600 with the queue *closed* — the
minimum window is the only size this app promises; 900x600 is worse
than 800x600, because the sidebar expands at 900, so the worst desktop
case is not the minimum and every test that stops at the minimum misses
it; at 320px with the queue open the main panel is 0px wide, because
the panel is in the flow rather than over it; only Playlists overflows,
so #69 is one view's action set and not a systemic header failure; and
both reasons in MinWidth's comment describe mechanisms that no longer
exist.

The queue's mode cannot be a media query: its width is drag-resizable
between 200 and 500px and persisted, so a fixed breakpoint assumes the
default 320 and is wrong by 180px in the direction that hurts. It is
computed from the measured widths instead.

#69 stays its own PR on a finding rather than an estimate: page-header
cannot collapse actions that arrive as arbitrary light-DOM markup
through a slot, so the fix needs an actions API across all three hosts.

A very small window becomes the phone layout, which already exists and
is already tested, rather than the mini-player: #12 is a second
always-on-top window, and making it a mode of the main window would
discard navigation state on a resize and put the process-level MPRIS
question on a path a drag can trigger.
2026-08-19 10:54:52 -04:00
logan 3607fe445e Merge pull request 'Fix the player states that report one track's progress against another' (#129) from fix/player-playing-state into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 6m14s
2026-08-19 14:53:46 +00:00
yonlu 61d549a9d5 ci: run the pre-push hooks sequentially
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m23s
CI / check (pull_request) Successful in 2m30s
`go test -race ./...` saturates every core for ~47s, and the UI tier
it was sharing them with is a real Chromium with wall-clock timeouts.
So the browser lost, at random: setup took 106s inside the hook
against 63s standalone, and a different suite failed on each run --
three failing to fetch setup.ts from Vitest's own dev server once, a
15s "did not mount itself" the next time -- against a suite that
passes 898/898 five times running on its own.

That reads as "your branch broke the frontend" when nothing is wrong,
which is the most expensive kind of false negative: the next person
bisects a change that was never at fault. It cost two pushes here
before the summary line gave it away.

Sequential costs about 15s.

Closes #128
2026-08-19 09:16:43 -04:00
yonlu 2b84bc53e9 fix(player): stop reporting one track's state against another
Five faults found while auditing the play/pause and position path for
a desktop report of the pause icon showing over a seek bar that was
not moving. They are one commit because they are one file's worth of
tangled state, and two of them do not compile apart.

The finished callback did not know which chain it came from. It is
dispatched as a goroutine from the beep callback and then queues for
p.mu, so a user pressing Next in the last second of a track had it
wake up holding the lock for a player that had loaded something else
-- and rewind it, stop it, and hand a stale finish to the queue's
auto-advance. updateStreamers now stamps a chainID and the callback
carries the one it was registered with. (#123)

It also emitted PlaybackFinished and PlaybackStateChanged(stopped)
*after* releasing p.mu, alone in this file, so a Play() taking the
lock in that gap emitted `playing` first and the stale `stopped`
landed last -- the button showing play over a track that was audibly
running. Both emits are back under the lock. (#123)

A source that failed mid-track was reported to the queue as a natural
end, so a broken file auto-advanced in silence and was counted as
played. The handler takes the reason now: the player cannot name the
track, because the metadata is the queue's, so the queue emits
PlaybackFailed and skips recording the play. (#123)

p.format was assigned once, in the constructor, to the *speaker's*
rate, and never again -- so it claimed 44.1 kHz for every file. The
replay-after-finish path resamples from it, meaning a finished track
played a second time was resampled from a rate the decoder never
produced: audibly wrong speed and pitch, and the length and position
fallbacks wrong with it. The fixtures are 22050 Hz, which is what lets
a test see this at all. (#124)

p.trackLengthMs was written only when the database had a row and
cleared only by UnloadTrack, so a file with no row inherited the
previous track's duration -- and every position report is scaled by
it, so the bar reported one track's progress on another's scale.
(#125)

Queue.OnPlaybackFinished indexed q.tracks[currentIndex] having checked
only that the queue was non-empty. currentIndex is -1 whenever the
queue has been exhausted, and onQueueExhausted deliberately leaves the
finished track loaded -- so playing it from there and letting it end
panicked, on a goroutine with no caller to recover it. (#126)

The position readers guarded the decoder with the speaker lock, which
the read-ahead goroutine has no reason to hold and never takes -- so
Position() raced readAhead's Stream() on every position emit, once a
second for the whole of playback. srcMu is the lock that excludes that
goroutine, and taking it naively deadlocks, because seekLocked already
holds it and then emits the landing position from inside that region.
seekSourceLocked is that region extracted, so the lock is released
before anything is emitted. Found by the race detector, via the test
added here for the chain guard: the existing suite never loads a file
outside the integration guard, so make test was green over it. (#127)

OnPlaybackFinished picks up //wails:ignore along with its error
parameter: v3's generator segfaults on a bound method taking an error,
and this was never IPC. That removes a binding the frontend could have
called to force an auto-advance.

Closes #123
Closes #124
Closes #125
Closes #126
Closes #127
2026-08-19 09:06:43 -04:00
yonlu 282dab43eb fix(player): end the stream when the audio source stops producing
BufferedStreamer.Stream treated an empty ring buffer as a momentary
underrun and answered with silence and ok. That is right while the
read-ahead is still going to deliver something, and two of its three
exit paths left it never going to: a Close, and a source returning
(0, true) in a loop. Neither set done, so the ring drained and every
call after it was silence claiming to be audio, for the life of the
process.

Nothing above this type could tell that from healthy playback. The
beep.Seq chain never ended, so the player stayed in Playing with the
button showing pause; the decoder's position never moved, so the 1 Hz
report pinned the seek bar at a constant -- and since every report
resets the bar's interpolation, the report actively suppressed the one
thing that would still have moved it. A frozen bar over a track that
was not playing, with no watchdog anywhere to notice.

Every exit now marks the stream done, and the silence fill is bounded
by a duration *and* a run of calls. It needs both. Wall clock is the
real measure, because the speaker paces itself and a stall is a
question about time -- but a caller draining in a tight loop makes
hundreds of calls in microseconds and would outrun a duration alone.
A call count alone is the opposite failure, and not a hypothetical
one: the first attempt used one and spent the whole budget before the
read-ahead goroutine had been scheduled once, ending a perfectly good
stream at sample zero and breaking TestBufferedStreamer_BasicStream.

Err is plumbed out at the same time, because a drained source and a
failed one both arrive as (0, false) and are not the same event.
Reading it is a separate change; without it there is nothing to read.

Closes #122
2026-08-19 09:05:55 -04:00
logan cc9df4004c Merge pull request 'Surface a confident autotag match on the album page' (#121) from feat/28-autotag-match-on-album into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m9s
The album page says when the autotagger has a confident match for what
you are looking at, and can apply it. The tier behind "confident" is
one name shared with strict auto-accept (#90), and the lookup costs no
MusicBrainz request.

Closes #28
2026-08-19 06:49:49 +00:00
logan b5d70ac1cd feat(explore): offer the autotag match on the album page
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m49s
CI / e2e (pull_request) Successful in 6m12s
The complaint was having to notice the metadata was missing, then go
and hunt the album down on the Autotag page. The album page now says it
while you are looking at the thing: "MusicBrainz has a match for this
album: <release> by <artist>", with Apply tags and Review in Autotag.

Four things about it are load-bearing.

**Applying is offered only where it would do the whole album.** A
tagging group is a folder, so a multi-disc album is several, and one
button that applied to the best-scoring group would leave the album
holding a mix of old and new tags — the exact case the app's Blocking
notification level exists for. `groupCount` is the test, and the answer
there is review rather than apply.

**It rewrites files, so it asks.** `confirmAction()` with an impact
line that says it cannot be undone and that nothing is moved or
deleted, because "rewrites your files" reads worse than it is. The
apply goes through `ApplyAsync`, the registered-job path, so progress
belongs to the jobs indicator and this page does not grow a second one
— what it owes the user is the acknowledgement, because the button is
here. The suggestion clears itself on success rather than inviting a
second click while the job runs.

**The banner does not quote a percentage.** The backend has a score and
deliberately keeps it out of the sentence: 0.95 reads as a probability
and is not one. Which release it is, is the part a person can judge.

**"Review in Autotag" lands on that album.** The queue is sorted by
score so the intended folder is often near the top, and "often" is a
link that sometimes opens a different album. Autotag is a cached
primary view, so there is no construction to hand a payload to: the
request goes on as an attribute and the view *consumes* it, or every
later visit would reopen a folder the user finished with long ago.

`ICON_AUTOTAG` joins the vocabulary at the same time, on the rule
`ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a
suggestion pointing at Autotag wears the Autotag destination's own
mark. It was written inline in the sidebar; two call sites is where a
name stops being one component's detail, so the sweep governs it now.

Verified against the running app with a staged match: the banner, the
confirm dialog's wording, and the navigation landing on the right
folder with the attribute consumed.

Closes #28
2026-08-19 02:34:11 -04:00
logan 9118c16fe3 feat(autotag): answer whether an album has a confident match
`MatchForAlbum(albumID)` is the question the album detail page needs to
ask on open: does the autotagger already have something confident to
say about this album, and what would applying it do.

**It costs no MusicBrainz request.** Everything it needs is on disk —
`tagging_items` carries the top score and release from the background
prefetch, `tagging_candidates` durably holds the scored list. The rate
limiters here are shared with every page the user can open, so a lookup
that fires on page load must not join that queue; a folder nobody has
scored yet answers "nothing", rather than scoring it now.

**The tier is computed, not read.** `tagging_items.score` is the raw
number and `Recommend` is what turns it into a claim, capping it for an
ambiguous runner-up, an incomplete alignment or a folder too small to
corroborate itself. Filtering on the stored score would promise
confidence the scorer had explicitly withheld — which the two-track
test pins.

**Nothing is said about an album the user has already answered for.**
Only a `pending` group qualifies: `confirmed` covers both a finished
apply and an explicit "leave as is", and arguing with the second would
be actively wrong.

The join is `audio_files.group_key`, not a key derived from the folder
path, because a group carved out of a mixed-bag folder is keyed on its
tags — so a path-derived key would find nothing for exactly the
messiest libraries this helps. `GroupCount` is returned because a
multi-disc album is one group per disc: a caller that applied to "the
album" from a single button would retag one disc of three.
2026-08-19 02:34:11 -04:00
logan fe67849e57 feat(autotag): name the confidence tier two features have to share
`ConfidentTier` and `Confident()` are a name for what was about to be
written as `== RecommendationStrong` at two call sites: the album page
telling the user unprompted that there is a match for what they are
looking at (#28), and strict auto-accept rewriting files without asking
(#90). A page that claims confidence the auto-accept pass would decline
is the app contradicting itself, and #90 asks for exactly this — that
the two agree on what "high confidence" means rather than computing it
twice.

What they do not share is written down beside it. Surfacing a match is
a suggestion with a confirm dialog behind it; auto-accept is an
irreversible on-disk rewrite gated on further conditions the tier
cannot express — exact track count, every title matching, lengths
within a couple of seconds, no cover replacement, no MBID conflict. So
this is the floor both stand on, not the whole of either test.

`Confident` is a rank comparison rather than an equality, so a tier
added above "strong" later does not silently stop qualifying.
2026-08-19 02:34:11 -04:00
logan 21b303ba7c fix(ui): stop a closing dialog answering the next question
`confirm-dialog` is one singleton for every confirmation in the app,
and `wa-dialog` reports its close asynchronously: `open = false` starts
an animation and `wa-hide` arrives after it. So a hide belonging to a
question already answered can land after the *next* question has
opened, and cancel it — the user is asked something, the dialog
vanishes on its own, and the call site is told they said no.

Each ask now carries an id. `close` ignores an id that no longer names
the question on screen, the button handlers pass none (they always mean
the current one), and only the `wa-hide` handler carries one, because
only `wa-hide` can arrive late.

Found by writing two `confirmAction()` tests in one file: the second
could not be accepted at all, because the first one's hide had
cancelled it before the click landed. Reaching it in the app needs two
confirmations close together, which the album page's "Apply tags" makes
possible.
2026-08-19 02:34:11 -04:00
logan 9375f25629 Merge pull request 'Demote the album page version selector to a disclosure' (#120) from feat/17-demote-version-selector into main
CI / check (push) Successful in 2m25s
CI / e2e (push) Successful in 6m0s
Choosing a pressing is a repair job, not the album page's headline.
The selector is a collapsed disclosure below the tracklist; the two
unguarded blocks that shared its slot are gone, and the catalog error
now belongs to the list that is missing because of it.

Closes #17
2026-08-19 05:38:26 +00:00
logan 905654cc84 feat(explore): demote the album page's version selector to a disclosure
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 5m59s
Choosing which pressing you are looking at is an advanced,
metadata-repair task, and it sat directly above the tracklist with a
heading, a `<select>` and a paragraph explaining how our clustering
picks a "standard version" by weighing release count, status and date.
That is a sentence about our own heuristic in the most valuable space
on the page.

It is now "Other versions of this album (N)" below the tracklist: a
real `<button aria-expanded aria-controls>` inside the heading that
names the section, with the body rendered unconditionally and toggled
with `hidden`, because `aria-controls` has to name an element that is
in the DOM. Both rules are `config-section`'s rather than new ones.
It is demoted, not removed — matching the wrong release is a real
problem and this is how it gets fixed.

**Two more blocks shared that slot and neither was guarded.** The
selector at least had `distinctTracklistCount() <= 1`; the
`Versions / Loading releases…` spinner and the `Versions / <error>`
block did not, so both took the primary position on every album
regardless of whether there was ever going to be a choice. The spinner
said what `renderTracklist` was already saying about the same fetch, so
it is gone. The error was the one `catalog-scope-notice` shows at the
top of the page with a retry — every path that sets `errorReleases`
also sets `catalogFailed`, the only route to `unavailable`.

That error is what made this a rewrite rather than a move.
`renderTracklist` returned `nothing` on `errorReleases` and leaned on
the selector's own block to have said it, and a control inside a
collapsed disclosure cannot be a page's error surface. The failure
belongs to the list that is missing because of it, so that is where it
is drawn.

**What must not be lost is which version is on screen.** The default is
what the header already describes, so saying it on every album would be
this issue's own complaint one size smaller. `defaultVersionKey` is the
test: a line appears above the tracklist only once someone has chosen
another, naming it and offering the way back. The ★ and the words "in
your library" survive unchanged inside the panel, and the panel does
not close when the selection changes — a panel that shuts on use cannot
be used twice.

The `<select>` also loses an `aria-label` of "Select release version"
that outranked its own visible `<label>Version</label>`, which is a
label not in the name.

Verified against the running app as well as the suite: the collapsed
page, the open panel, a chosen version and 390px width all read
correctly, and the shell still measures 390 in a 390 viewport.

Closes #17
2026-08-19 01:24:49 -04:00
logan 219fa3c615 Merge pull request 'Make it obvious everywhere when you are looking at things you do not own' (#117) from feat/38-ownership-visibility into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 6m3s
Owned is plain; unowned is dimmed, named and requestable; a partly-held
album says how partly. Ownership is a file (`localId`), never the
`in_library` ratchet.

Closes #38
2026-08-19 05:11:43 +00:00
logan c4e055ce51 docs: write down which of the two ownership columns to read
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m15s
The `localId` / `inLibrary` choice outlives #38 — every future catalog
surface has to make it, and the code read them as an OR at eight call
sites precisely because nothing said they were different kinds of
thing. CLAUDE.md gets the rule and its four load-bearing details;
NOTES.md gets the measurement, the card that used both answers at once,
and the alternative that was rejected.
2026-08-19 00:50:53 -04:00
logan 10eca353ab fix(explore): gate playback on the same answer the row is drawn from
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m53s
CI / e2e (pull_request) Successful in 6m24s
Two play paths still accepted `inLibrary`, so a row drawn dimmed and
`aria-disabled` by the new rule would still attempt to play and fail
with "this track could not be found in your library" — the disagreement
this pass exists to remove, one layer down from the badge.
2026-08-19 00:39:18 -04:00
logan 88fc50afb8 feat(explore): mark what is not owned, everywhere it can be shown
`explore-album-details` had the rule right for one tracklist and
nothing else did: Explore's cards, `top-results-row` and the artist
page's three card shapes all mixed owned and unowned with a small badge
as the only difference, and drew a green tick on the *common* case —
which is the treatment that tracklist's own green ticks were removed
for.

`utils/ownership.ts` is the rule written once, so eight call sites
stop each holding their own version:

- owned is plain, and draws no badge at all;
- unowned is dimmed *and* says so in its accessible name, because
  dimming is a colour and cannot be the only signal;
- a partly-held album says how partly.

**Ownership is a file, and `localId` is the flag that says so.** The
album page answers with `filePaths`, a real file per displayed track; a
card grid cannot afford that and does not need to, because
`local_*_id` is built by queries that all join `audio_files` and
cleared by a prune whose existence test is a file test in every case.
`inLibrary` is written by the same pass, so the two agree in a healthy
database — but it is a one-way ratchet (`MAX(in_library, excluded)`)
whose only clearing pass is gated on a non-null local id, so it cannot
be un-set on its own.

Where they already diverged was the client. Both `explore-view` and
`explore-artist-details` kept a `libraryMBIDs` set that accumulated
every MBID ever seen with `inLibrary` and cleared it never, in views
that never unmount. Both are deleted.

And one card answered the question twice and got two answers:
`renderReleaseMenuItems` gates Play on `localId > 0` while the badge
and `albumTarget.owned` used `inLibrary`, so an album with the flag and
no local row drew a tick saying it was in your library, offered no
Play, and — the request item being gated on *not* owned — offered no
way to ask for it either.

The count comes from `completenessStore`, shaped like `credit-store`:
`request()` is per-card and coalesces a screenful into one
`GetAlbumsCompleteness`, absence is cached as an answer, and the whole
cache is dropped on a scan, a retag or a removal rather than aged.

`aria-disabled` goes on rows that cannot be activated and deliberately
not on cards: an unowned card still navigates to the catalog page for
it, which is a perfectly good thing to do with something you do not
own.

Audited and unchanged: `home-view`, `downloads-view`, `cover-grid`,
`artist-details` and `genre-details` cannot show catalog content, so
everything on them is owned and "owned is plain" is already what they
do. The album page's own header badge stays, because that page is about
one entity and the badge is its answer rather than a mark on one of
many.

Closes #38
2026-08-19 00:38:22 -04:00
logan 19c68d73a7 fix(ui): keep the count in a partial badge that can act
A control is named after what activating it does, so an actionable
badge said "Request album X" — and `partial` is actionable, because an
album you hold nine of twelve tracks of has three left to ask for.
That made the one state the ring exists for the one state whose name
did not mention it.

The argument the `partial` branch already carries does not stop
applying because the badge became clickable: a ring says "some" to a
sighted user and nothing to anyone else. The name is now the action and
the count.
2026-08-19 00:38:05 -04:00
logan 41c41a860e feat(explore): carry the local row id on a top result
`TopResult` was the one projection here that shipped `inLibrary` and no
local id, so the top-results cards had no choice but to read the weaker
flag. Every sibling model — `MBArtist`, `MBReleaseGroup`, `MBRecording`
— already carries `LocalID`, and the candidate builders had the value
in hand at every construction site.

`LocalID` is set and cleared by a test against `audio_files`, so it
means "there is something of mine here". `InLibrary` is written by the
same pass but is a one-way ratchet the prune can only clear alongside a
local id; it stays for scoring, which is where an approximate answer is
fine.
2026-08-19 00:37:57 -04:00
logan 4bf59b45b7 feat(library): answer album completeness for a screenful in one query
A card grid has to know how much of an album is here — an album held 2
tracks of 10 wearing the same green tick as one held whole is the
complaint the badge-accuracy work was filed about — and
`GetAlbumCompleteness` is one query per album, which is fifty round
trips for a grid of fifty.

`GetAlbumsCompleteness` is the same question over a slice. It is two
grouping levels rather than the single-album form's correlated
subqueries, because a correlated subquery in the FROM clause is not
something SQLite will reliably do, and because the slice may only be
spelled once or sqlc expands it twice with independently numbered
placeholders.

An album with no files is absent from the result rather than zeroed:
"I have none of this" and "I have no idea" are the third state `Known`
exists to keep apart.

The test that matters is that the two spellings never disagree — they
are genuinely different SQL, so the risk is a drift in meaning (a
disc's total counted once per file, a duplicate counted twice) rather
than a typo.
2026-08-19 00:37:46 -04:00
logan fc99d9e0d7 Merge pull request 'Make a release a shipment rather than a merge' (#116) from ci/115-manual-release into main
CI / check (push) Successful in 2m24s
CI / e2e (push) Successful in 6m11s
Closes #115
2026-08-19 02:36:01 +00:00
166 changed files with 18918 additions and 1473 deletions
+6
View File
@@ -195,6 +195,12 @@ Two rules about climbing:
- **Do not write an e2e spec first.** Drive the flow by hand, then - **Do not write an e2e spec first.** Drive the flow by hand, then
promote it with `/e2e`. Specs written blind assert on selectors that promote it with `/e2e`. Specs written blind assert on selectors that
do not exist. do not exist.
- **Not every view has a nav item.** Since #25 the destinations are
configurable, Autotag is hidden by default and Downloads is absent
until a download client exists — so `getByTestId('nav-<view>')` waits
30 s for a locator that will never resolve. `navigateTo(page, view)`
(`e2e/support/fixtures.ts`) dispatches the app's own `navigate` event.
Click the nav item when the *nav* is what the spec is about.
Before a commit, the gate is `make lint`, `make test`, `make ui-test`, Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
`make bindings-check`, `make css-check` and — from `frontend/` `make bindings-check`, `make css-check` and — from `frontend/`
@@ -194,9 +194,13 @@ like the app's fault and none is:
|---|---|---| |---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 | | x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL | | arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | unverified, still | | arm64, real device | **runs** (2026-08-20) | — |
**A physical arm64 device remains the only verification path.** **A physical arm64 device remains the only verification path**, and it
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
launches and stays up; `make android-smoke SECONDS=60` passes on it.
What that run *found* is the lifecycle fault below.
### What was fixed to get here ### What was fixed to get here
@@ -210,6 +214,11 @@ no-op. `backend/system` gained no import of the Wails application
package, which matters for the same reason `backend/events` is split by package, which matters for the same reason `backend/events` is split by
the `indexbuild` tag. the `indexbuild` tag.
**And `main()` is now latched to one run per process** (#52). That is
the second `os.Exit(1)` in this file's history and it had the same
signature as the first, which is the argument for #160: both were named
exactly by an `slog` line that went to `/dev/null`.
### What is still not done ### What is still not done
The shell is still a desktop shell, and the x86_64 half of the APK is The shell is still a desktop shell, and the x86_64 half of the APK is
@@ -249,10 +258,25 @@ one.
`build/android/Taskfile.yml` ships more than the Makefile wraps, and `build/android/Taskfile.yml` ships more than the Makefile wraps, and
they are the right thing to reach for when you want something one-off: they are the right thing to reach for when you want something one-off:
> **These four were unsafe until #159 and are now the way in.** All of
> them began with `adb uninstall {{.APP_ID}}`, where `APP_ID` defaulted
> to `app.yellowjacket` — the **release** id — while `run` and
> `run:device` build the **debug** variant, whose id is
> `app.yellowjacket.dev`. So they uninstalled the user's app, taking
> the library with it, installed a different package, and then failed
> to launch the one they had removed.
>
> They share `scripts/android-deploy.sh` now, which **never**
> uninstalls (`install -r`, and a changed signing certificate is
> reported with the command rather than acted on), reads the package id
> back out of the built APK, and refuses a target that is not the kind
> the task names. There is nothing left to avoid; the manual sequence
> below is kept because it is still the smallest thing that works.
``` ```
wails3 task android:run # debug build + emulator install + launch wails3 task android:run # debug build + emulator install + launch
wails3 task android:run:device # same, first connected physical device wails3 task android:run:device # debug build + install + launch on a phone
wails3 task android:deploy-device # production APK to a device wails3 task android:deploy-device # release build, same
wails3 task android:bundle:fat # AAB, for a Play Store upload wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list wails3 task android:device:list
@@ -260,6 +284,16 @@ wails3 task android:logs:all
wails3 task android:clean wails3 task android:clean
``` ```
**`run` and `deploy-emulator` mean the emulator, and now say so to
adb.** They used a bare `adb install`, which with exactly one device
attached picks that device whatever it is — so with a phone plugged in
and no emulator running, the task whose summary reads "in the Android
Emulator" installed on the phone. They pass `--target emulator` and
refuse with `make android-emulator` as the remedy.
**`DEVICE_ID=<serial>` still names a device, and several attached
devices is now an error rather than a silent pick of the first.**
Two are deliberately **not** wrapped. `android:logs` greps logcat for Two are deliberately **not** wrapped. `android:logs` greps logcat for
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses `(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails` the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
@@ -269,16 +303,51 @@ instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced. sequenced.
## The identity is declared twice ## The identity is read back from the APK
It used to be **declared twice**, and that is what #159 was.
`applicationId` in `build/android/app/build.gradle` is what Gradle `applicationId` in `build/android/app/build.gradle` is what Gradle
installs. `APP_ID` in `build/android/Taskfile.yml` is what every installs; `APP_ID` in `build/android/Taskfile.yml` was what every
adb-driven task uninstalls, launches and filters. **Nothing enforces adb-driven task uninstalled, launched and filtered, and nothing
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in enforced that they agree. They did not: the debug buildType carries
`build/config.yml` does not work in beta.8 — `wails3 task` never reads `applicationIdSuffix ".dev"`, so every task that assembles a debug APK
that file (verified with `--dry`), and even when set it feeds only the addressed the release id. This file flagged the hazard for five phases
adb commands, never Gradle. Change both or the official `run`/`deploy` and it cashed out twice — once as a wrong `am start`, once as an
tasks address a package that is not installed. uninstall of the user's library.
**`scripts/android-pkgid.sh` is the one answer now.** It prints the
package id an APK declares (`aapt2 dump packagename`, falling back to
`aapt dump badging`), and the deploy path installs and launches *that*.
The APK is the authority because the task that installs it has just
built it: whatever Gradle resolved the applicationId to, suffixes and
flavours included, is in the file, and no default can disagree with it.
An APK it cannot read is a hard failure, never a fallback to a written
down default — guessing is the bug.
**`APP_ID` survives as an assertion, not a setting**, and has no
default. `wails3 task android:run APP_ID=app.yellowjacket` says "this
build had better declare that id" and is refused, naming both, *before*
anything is installed or a device is even chosen. It could never have
been a setting: `ANDROID.md`'s advice to put it in `build/config.yml`
does not work in beta.8 — `wails3 task` never reads that file (verified
with `--dry`) — and even when set it fed only the adb commands, never
Gradle.
`scripts/android-emulator.sh` derives `PKG` the same way, from
`bin/yellowjacket.apk` when one is built, so `make android-install`,
`android-launch`, `android-logs` and `android-smoke` follow whichever
variant is actually in `bin/`. `YJ_ANDROID_PKG` still overrides, and
the old literal survives only for a tree with no APK built yet.
**The uninstall is gone and is not coming back.** It existed to make
the bare `install` on the next line work at all — without `-r` Android
refuses an install over an existing package — so `install -r` removes
the *reason* for it rather than merely removing it. What is left is the
one case an uninstall really is the remedy, a changed signing
certificate, and that is exactly the case where performing it silently
costs the user their library. So it is named and not done, which is the
answer `scripts/android-emulator.sh` had already reached for
`make android-install`.
Related, and it will bite once: the launcher activity is Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is `com.wails.app.MainActivity` and the applicationId is
@@ -287,6 +356,27 @@ resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the class-not-found that reads like a broken build. Always the
fully-qualified form. fully-qualified form.
**`wails3 task android:run:device` is the way to put a debug build on a
real device**, since #159. What #52 used, before it was safe, was the
longer form, and it is still the smallest thing that works if you want
no script between you and adb:
```bash
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
adb install -r bin/yellowjacket.apk # -r, never uninstall
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
```
The id in that last line is the one thing to keep an eye on by hand —
`./scripts/android-pkgid.sh bin/yellowjacket.apk` is what the tasks ask,
and it is a good habit before any `am start` written out in full.
`YJ_ANDROID_PKG=app.yellowjacket.dev` still overrides what
`scripts/android-emulator.sh` — and therefore `make android-smoke`,
`android-logs`, `android-launch` — addresses, but it is rarely needed
now: that default is read from `bin/yellowjacket.apk`, so it already
follows whichever variant was built last.
## What only a device can answer ## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them The emulator cannot run this app (three separate reasons, none of them
@@ -311,6 +401,91 @@ system bars, the back gesture, focus and audio interruptions,
permission dialogs, the keyboard — not about what the app draws. The permission dialogs, the keyboard — not about what the app draws. The
drawing is what the other five tiers already cover. drawing is what the other five tiers already cover.
**The third such fault was the activity lifecycle** (#52), and it is
the one to re-check after touching `main()`, `WailsBridge` or
`MainActivity`. Android destroys and recreates an activity **without
restarting the process**, and Wails' `nativeInit` — which
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
is still true behind Android's `select{}`), and the `os.Exit(1)` under
it took the healthy first app down with it.
### The lifecycle check, and how to trigger it on demand
This is the regression guard for #52 on this tier, because no other
tier runs `main()` on Android at all. The Go-side guard
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
latch; only the device catches the latch not working.
**Trigger a relaunch with a configuration change the manifest does not
declare.** `AndroidManifest.xml` lists
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
in-place and are *not* triggers. `fontScale` is not listed, and it is a
one-liner:
```bash
adb shell settings put system font_scale 1.15 # restore the old value after
```
That is the same in-process destroy/recreate that "Don't keep
activities", a locale change and a memory trim produce, but on demand.
**"Don't keep activities" is the report's own lever and did not work on
this device**: `settings put global always_finish_activities 1` reads
back as `1`, `am set-always-finish-activities` does not exist on this
build, and the activity was never finished on backgrounding. Do not
spend an afternoon on it; use the config change.
**The assertion is the pid, and the tell is two bridge inits in one.**
```bash
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
```
Healthy is one pid appearing twice — the process surviving the
recreation:
```
I/WailsBridge(28420): Wails bridge initialized
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
```
Broken is that pair followed within a second by:
```
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
```
Two things about reading that. **`has died: fg TOP` is not a memory
kill** — the system does not reclaim the foreground process, so this is
the app leaving of its own accord. And there is **no crash record
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
and it is why "the system killed it" is the wrong first hypothesis.
**Surviving is only half of it — check the recreated WebView is still
wired to the running app.** A plausible-looking fix (making
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
keeps the process alive and silently breaks this, because `nativeInit`
is also what re-points the JNI reference at the new bridge. Go would go
on executing JavaScript against the destroyed activity's WebView: the
app opens, renders, and never receives another backend event.
Ask the page, after a relaunch and a resume:
```bash
make android-inspect
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
# background and foreground the app, then:
make android-eval EXPR='JSON.stringify(window.__probe)'
```
A healthy build answers with events from the live services —
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
`[]` means the bridge reference is stale.
## Asking the device, not just looking at it ## Asking the device, not just looking at it
A real phone can be inspected, and that turns this tier from "reported A real phone can be inspected, and that turns this tier from "reported
@@ -340,6 +515,75 @@ 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. 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 - **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered. 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.
### 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 **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 first device here renders in **Chrome 113** at 424x439 CSS px. Every
+1173
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
# 018 — Supported sizes, and what the queue panel is
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
**Status:** complete — #24 shipped as PR #132, and the matrix's last
unkept promise closed with #69.
#73 puts this first in Phase 2 and hangs the rest of the phase off it,
so the decision has to be written down and arguable before any CSS
moves. This document is the decision. Everything below the matrix is
either a measurement or an argument for one of the four choices #24
asks for.
---
## What is actually wrong, measured
Against the running app (`make dev-headless SEED=default`, Chromium),
Playlists, sweeping the viewport with the queue open and closed. The
number that matters is how much of the page header survives.
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|---|---|---|---|---|---|
| 1280×800 | 200 | open 321 | 759 | 759 | — |
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
| **900×600** | 200 | open 321 | **379** | 747 | **all three** |
| 800×600 | 56 | open 321 | 423 | 747 | all three |
| 700×600 | 56 | open 321 | 323 | 747 | all three |
| 390×780 | — | open 321 | **69** | 747 | all three |
| 320×600 | — | open 321 | **0** | 747 | all three |
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
| **800×600** | 56 | closed | 744 | 747 | **New Smart Playlist (158/162px)** |
| 320×600 | — | closed | 320 | 747 | all three |
Five things in that table are not in the issue.
**The header clips at the supported minimum with the queue closed.**
At 800×600 — the size `backend/config/window.go` enforces and the only
size this app *promises* — "New Smart Playlist" loses 4px of its 162.
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
header overflows on its own at the minimum window.
**900×600 is worse than 800×600, because the sidebar expands at 900.**
`AUTO_COLLAPSE_VIEWPORT` collapses the sidebar to icons *below* 900, so
at 899px the main panel is 843px and at 900px it is 700px. The worst
desktop case is therefore not the minimum window; it is the pixel
immediately above the collapse. Anything that tests "the minimum" and
stops has not tested the worst case, which is what
`layout-overflow.spec.ts` does today.
**At phone widths the queue is not a drawer, it is an amputation.**
`queue-panel`'s host is `flex-shrink: 0; width: 0`, going to
`width: var(--queue-width, 320px)` under `[open]` — it is *in the flow*
of `.content-area`, so it takes its width from the main panel rather
than covering it. At 390px that leaves 69px of the page; at 320px it
leaves **0px**, and the app is not degraded but gone. This is the
measurement #55 needs and did not have.
**Only Playlists overflows.** Sweeping all ten primary views at 900×600
and at 390×780, every other header reports `scrollWidth ==
clientWidth`, and Albums at 390px renders title, count and sort
legibly (checked on a screenshot, not just the number). #69 is
therefore one view's action set — three text buttons totalling 390px —
and not a systemic header failure, though the *rule* still belongs in
`page-header`.
**Both reasons in `MinWidth`'s comment are stale.** It says the floor is
800×600 because "below ~780 the header's subtitle wraps" and "below
~600 tall the eleven sidebar items no longer fit". The subtitle is
`display: none` below 900 (index.css), and the sidebar host is
`overflow-y: auto` — at 600×460 its `scrollHeight` is 434 against a
332px client, and Settings is reachable after scrolling. Neither
mechanism can happen any more. That does not mean the floor should
move; it means its stated reason no longer supports it, which is worse
than either answer.
*(Care needed: my first probe for the sidebar scroller searched
`shadowRoot.querySelectorAll('*')` and reported "items are
unreachable", because the scroller is the **host** and a host is not in
its own shadow root. The claim in CLAUDE.md is correct.)*
---
## Decision 1 — the supported size matrix
Three bands. Two of them already exist and are already argued; what is
new is that they are written down as a *promise*, and that the queue is
part of it.
| band | width | navigation | queue | promise |
|---|---|---|---|---|
| **Phone** | < 600 | `bottom-nav` + drawer | overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
| **Compact** | 600 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
| **Desktop** | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
And one promise across all three: **no action is ever unreachable.**
That is the sentence #69 asks for and it is the one the matrix exists
to make checkable.
**400% zoom** keeps the meaning it already has: WCAG 1.4.10 names 320px
as the reflow target, the phone band covers it, and
`layout-overflow.spec.ts` already asserts a 320px viewport needs no
sideways scrolling. What changes is that the *queue* must be part of
that assertion — it is not today, and with the queue open at 320px the
main panel is 0px wide, which no current test can see.
**The window minimum stays 800×600**, and its comment gets the real
reason. The old mechanisms are gone, but the floor is still where the
Compact band's chrome stops being comfortable, and lowering it would
mean promising the desktop layout at sizes where only the phone layout
works. The interesting consequence is decision 4.
---
## Decision 2 — the queue is an overlay when it cannot afford to be a column
**The rule.** The queue panel renders inline — in the flow, as today —
only while
```
viewport sidebar queueWidth ≥ 480
```
and as an overlay with a scrim otherwise.
**Why it cannot be a media query**, which is the load-bearing half:
the queue's width is *user state*. It is drag-resizable between 200 and
500px and persisted (`--queue-width`, `MIN_WIDTH`/`MAX_WIDTH` in
`queue-panel.ts`). A breakpoint at a fixed viewport width silently
assumes the default 320, and is wrong by 180px for a user who has
dragged the panel wide — in the direction that hurts, since a wider
queue is exactly when the content can least afford it. So the mode is
computed from the measured widths and published as an attribute, the
way `data-active-view` already is, and the CSS keys off that.
**Why 480, honestly.** There is no cliff to derive it from. The track
list rescales its columns continuously — at main widths from 900 down
to 544 its `--grid-cols` shrink from 213px to 124px with
`rowOverflow=0` throughout — and the album grid steps 3 columns to 2
somewhere between 564 and 644 without breaking. So this is a judgement,
anchored on two things: it keeps the *default* window (1100 wide, main
= 580) inline, because the inline queue is a desktop affordance people
choose and turning it into an overlay for the common case would be a
regression in feel; and it puts every case measured as broken —
900×600 at main=379, and every phone width — on the overlay side.
1024×768 lands at main=504 and stays inline.
**The scrim is the other half of the issue's complaint** ("make the
queue obviously an overlay *over* the content so it reads as something
to close"). An overlay queue gets a scrim, closes on scrim click and on
Escape, and returns focus to `#queue-button`.
**What must not change**: #55's Direction is explicit — one component,
two mount points, do not fork it. The overlay is a *presentation* of
the same `queue-panel`, so the roving tab stop, Alt+Arrow reorder, drag
reorder, selection semantics and the `virtualizer.requestUpdate()` on
selection and current-track change all come along untouched. This
decision deliberately stops short of #55's detail-view mount, but it is
the shape that makes it possible, and it unblocks it.
---
## Decision 3 — #69 is its own PR, and here is the finding that decides it
`page-header` **cannot collapse its own actions**, and that is not an
effort estimate but a fact about the API. Actions arrive through
`<slot name="actions">` as arbitrary light-DOM markup — Playlists slots
a `<div class="header-actions">` of three `<button>`s with click
handlers, drag handlers and a conditional class. A component cannot
move another component's light-DOM children into a dropdown and keep
their behaviour; there is nothing generic to render as a menu item.
So the overflow rule needs an *actions API* — hosts declaring
`{icon, label, handler, priority}` data that `page-header` can render
either as buttons or as menu items — which is a change to all three
hosts that slot actions, not a rule added in one place. That is a
different piece of work from this one, it is independently verifiable,
and the desktop half of #69's symptom is removed by decision 2 anyway
(the queue stops eating the header's width).
It therefore stays #69, gets the finding above recorded on it, and
follows immediately after this. What *this* plan owes it is the
promise in the matrix — no action unreachable at any supported size —
and the measurement that the only offender today is Playlists.
**And the promise is not kept yet, which is the honest version of a
claim this document made in its first draft.** "Decision 2 removes the
desktop half of #69's symptom" was too strong. Measured after phase 2,
at 900×600 on Playlists:
| | before | after |
|---|---|---|
| queue open | main 379px, **all three** actions clipped | main 700px, **one** clipped |
| queue closed | main 700px, one clipped | unchanged |
So the queue's *contribution* is gone — open and closed are now
identical, which is the whole of what this decision owed — and the
residual "New Smart Playlist: 114/162px" is the header overflowing on
its own, at a size the queue never touched. #69 is still a live defect
at a supported size, and the matrix's promise is what will close it.
---
## Decision 4 — a very small window becomes the phone layout, not the mini-player
#24 asks whether a very small window should switch to the mini-player
(#12) "or simply refuse to go there". Both options in the question are
worse than the one the codebase already has.
**#12 is a second window, not a mode.** Its findings say so: v3
supports multiple windows, `AlwaysOnTop` is a window *option*, and the
frontend would need an entry branch mounting only the mini-player root
for a second window loading the same bundle. Turning the main window
into a mini-player at some width conflates the two: it would throw away
the user's navigation state on a resize, and it puts the MPRIS question
(#12's own open question — media controls are process-level and must
not be per-window) on a code path that a drag can trigger by accident.
**And "refuses" is unnecessary, because the reflow already exists.**
The phone band is real, tested, and reached by width alone — a desktop
window narrowed below 600px already gets `bottom-nav` and the phone
shell. That is a better answer than refusing: it is strictly more
usable than a hard minimum, it costs nothing new, and it is the same
code Android runs, so it stays exercised.
So: the main window reflows and never becomes a mini-player; #12 stays
a separate always-on-top window and is not blocked by, or coupled to,
this decision. The window minimum stays 800×600 for the reason in
decision 1 — but the phone band is what happens below it, not a
refusal, which is why the minimum is a comfort floor rather than a
correctness one.
---
## Phases
1. **This document**, linked from #24, with the matrix reported on the
issue and #55 told whether it is unblocked. *(no code)***done**
2. **The queue's overlay mode** — computed mode attribute, scrim,
Escape and scrim-click close, focus return. The inline path is
unchanged above the threshold. — **done**
3. **The window minimum's comment** — replace both stale reasons with
the measured ones. No value change. — **done**
4. **Verification**, below. Including the specs that must change
because they assert the old behaviour. — **done**
#69 follows as its own branch; #55 became unblocked at phase 2.
## What landed, measured
Main panel width with the queue open, before and after:
| viewport | before | after | mode |
|---|---|---|---|
| 1280×800 | 759 | 759 | inline |
| 1100×720 (default window) | 579 | 579 | inline |
| 1024×768 | 503 | 503 | inline |
| 900×600 | **379** | **700** | overlay |
| 800×600 | 423 | 744 | overlay |
| 390×780 | **69** | **390** | overlay |
| 320×600 | **0** | **320** | overlay |
The scrim is perceptible but subtle on a dark ramp, which is worth
knowing before someone "fixes" it: sampled from the screenshots at
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
row's text 242 → 133. It covers the **content area only** — not the
sidebar or the transport — on purpose: the queue is not modal, and
leaving the navigation live means the scrim reads as "this is over the
content" (which is what #24 asked for) without pretending the rest of
the app is unavailable.
## What #69 did with the promise, and one thing this plan got wrong
#69 landed on its own branch as decision 3 said it would, and the
matrix's *no action is ever unreachable at any supported size* is now
kept rather than promised. Measured on Playlists, actions clipped:
| viewport | before #24 | after #24 | after #69 |
|---|---|---|---|
| 900×600, queue open | all three | one (114/162px) | none |
| 900×600, queue closed | one | one | none |
| 800×600, queue closed | one (158/162px) | one | none |
| 390×780 | all three | all three | none |
| 320×600 | all three | all three | none |
The shape was the one decision 3 predicted — an actions API first, an
overflow rule second — and all three hosts that slot actions migrated.
**What this document got wrong is smaller and worth keeping.** Decision
1 says the header's minimum is a *comfort* floor and that only the
queue and the actions compete for the header's width. They are not the
only two: every child of that flex row was `flex-shrink: 0`, so
whatever came last lost, and the actions come last. At 320px the sort
control alone is 172px of the header — so with every action already
collapsed into the menu, the *menu button* was 76px off the right edge.
The promise was still broken with nothing left to collapse.
That is why #69 also had to decide what gives way: the title (which the
navigation also states) and, below 600px, the word "Sort:" (which the
direction arrow implies). Neither is an action, which is the rule the
matrix actually encodes — **an action is a capability and everything
else on that row is a label.**
## Verification, and what each tier cannot see
- `make ui-test` — the queue panel's mode logic is component-tier
work and belongs there. It **cannot** see the shell: the threshold is
computed from the sidebar and viewport, which do not exist in that
tier.
- `make e2e``layout-overflow.spec.ts` gains the queue-open case at
every band (it has none today, which is why main=0px at 320px has
never failed anything) and **gains 900×600**, since the minimum is
not the worst case. `queue-toggle-state.spec.ts` and
`phone-shell.spec.ts` both touch the panel and must be re-read before
editing.
- **Screenshots at every band, read by a human.** This is not optional
here: `layout-overflow.spec.ts` asserts the *shell* needs no sideways
scrolling and passes on a build whose album header clips its own
buttons (measured this session at 390px; filed on #66). Clipping
*inside* a component is invisible to it, and clipping is this issue.
- `make ui-visual` **cannot help at all** — the component tier renders
the token fallbacks, because the theme only reaches `:root` in the
real app.
- Accessible names via `page.getByRole(...)`, never a shadow-root
query. A drawer with a scrim is exactly the shape that grows a
nameless control, and this repo has shipped one three times.
+830 -4
View File
@@ -317,6 +317,60 @@ stacking dialogs. Window state moved off that path entirely, onto a
window still exists and `OnShutdown` has neither a context nor a window still exists and `OnShutdown` has neither a context nor a
window. window.
**An activity is a view onto the process, and `main()` runs once per
process.** On Android the Wails entry point is `nativeInit`, which
`MainActivity.onCreate` calls — and it does two things: it re-points the
native library's global JNI reference at the calling `WailsBridge`, and
it runs `go mainFunc()`. Android destroys and recreates an activity
**without restarting the process** (a configuration change the manifest
does not declare, memory pressure, or every background under "Don't keep
activities"), so `main()` ran again on a live app. `application.New`
returns the *existing* app rather than building a second one,
`app.Run()` then refuses — `a.starting` is still true, because Android's
`platformRun` is `select{}` and never returns — and the `os.Exit(1)`
under that error took the **first**, healthy app down with it: its
database, its queue, and the audio a `mediaPlayback` foreground service
was holding the process alive to play. `mainStarted` latches it, first
statement in `main()`.
Four things about it are load-bearing.
**The answer to "restore the session or cold-start" is settled by
playback, not by preference.** The audio lives in the Go process, so a
cold start on every activity recreation would stop the music mid-song —
which is the exact thing the foreground service exists to prevent. The
activity is a view; the app is the process. The frontend already
cooperates, because a recreated WebView loads the page fresh and fetches
its state from a backend that never went away.
**Returning early is not a degraded mode, and that is why the latch is
in Go rather than in Java.** The obvious fix — making
`WailsBridge.initialized` `static`, so the second `nativeInit` is
skipped — keeps the process alive and silently breaks the app, because
skipping `nativeInit` skips the reference re-point too: Go would keep
executing JavaScript against the *destroyed* activity's WebView, and the
app would open, render, and never receive another backend event. The
latch lets `nativeInit` do its first job and declines only its second.
**`ServiceShutdown` has never run on Android**, and nothing should be
built on the assumption that it will. `App.Quit()` reaches an
`androidApp.destroy()` that is an empty method, and `Run()`'s deferred
`shutdownServices()` cannot fire behind `select{}`. Durability on this
platform is the persist writers, which submit on every mutation rather
than at exit — which is also why `MainActivity.onDestroy` no longer
calls `bridge.shutdown()`: the activity going away is not the app
shutting down, and there is no callback for the process going away
because Android simply kills it.
**No tier here can see any of this**, so the guard is split. A source
sweep (`TestMainClaimsBeforeItDoesAnything`) asserts the latch is the
*first* statement of `main()` — the failure it exists for is not
deletion, which is loud, but a line creeping in above it, since a second
`NewYellowJacketApp` opens the SQLite database again on every
recreation. The rest is a documented device check in
`.pi/skills/yellowjacket-dev/references/android-tier.md`, with the
logcat signature and a one-line way to force a recreation.
`internalServiceMethods` auto-excludes `ServiceStartup`, `internalServiceMethods` auto-excludes `ServiceStartup`,
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this `ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
shape **removed** 12 spurious bindings and the bogus `context` model shape **removed** 12 spurious bindings and the bogus `context` model
@@ -514,6 +568,63 @@ rather than renaming them.
the autotag apply are registered; anything that is not registered has the autotag apply are registered; anything that is not registered has
none of that, which is exactly how the three gaps the audit found none of that, which is exactly how the three gaps the audit found
came about. came about.
**Its rows are shown where the work is started, not on a page of
their own.** #27 folded the Jobs destination away, and the shape it
folded into is `<job-panel kinds="…">` embedded four times — scans in
Settings → Libraries, index and enrichment in Settings → Search
Index, downloads under the download clients, the autotag apply in
`autotag-view`. One "Background jobs" section in Settings was the
obvious reading of the report and is the tab again under another
name.
Four things about it are load-bearing. **Four of the five kinds
already had a home** that showed their work — the tier list, the
download list, the apply ring — and what none of them had is the
*generic* affordances, so the panel carries pause, cancel, Details
and the log to each rather than replacing what is there. **The
controls are `applyJobControl`**, not a reimplementation, which is
what keeps the "you will discard hours of downloading" confirmation
alive: it is keyed on `KindIndexBuild` inside the shared handler, and
a host drawing its own buttons would drop it silently. **A panel with
nothing to say is `hidden`**, host margin included, because an idle
panel in four places is four pieces of furniture describing an
absence. And **there is no "Clear finished"** in it, because
`ClearFinishedJobs` is global — a Clear under Libraries would discard
the index build's history too; a finished row dismisses itself.
The header `job-indicator` is still the one view of everything at
once, from every page — **on a desktop.** One consequence worth
knowing before writing a spec: a section holding a `job-panel` also
holds a `job-details-drawer`, whose own header carries `.header` — so
`config-section .header` is ambiguous the moment a job exists.
**Below 600px that indicator stands down and `<job-band>` takes
over** (#62), because a popover is a *disclosure* and background work
is the one thing a phone should not make you open something to see —
and because #57 deletes the bar it is anchored to and is blocked on
it having somewhere else to live. The band is the same `job-panel`,
so `applyJobControl` and its index-build confirmation come along
rather than being reimplemented; `kinds="*"` is how it says "every
kind", which is what the indicator was for.
Three things about it are load-bearing. **It is in the layout, not
over it**, as its own grid row above the main panel: the first
version put it in `notification-host`'s fixed band, which reads fine
in a screenshot and is unusable — at 424×439 a compact panel is
~200px of a 439px screen and it *covers* what is under it, which four
e2e specs caught by failing on taps it was intercepting. **It shows
active work only** (`active-only`), because in flow a finished row is
furniture that keeps the content pushed down after the work is done;
finished rows stay where the work was started, which is #27's rule.
And **it renders nothing above 600px**, from `matchMedia` rather than
a media query, because that decides whether the element *exists*
Settings already holds four `job-panel`s and a fifth answering for
every kind is `bottom-nav`'s "resolved to 2 elements" trap again.
`index.css` keeps it `display: none` outside 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.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments. - `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists. - `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — OS media controls behind one `Handler`: MPRIS over - `mediacontrols` — OS media controls behind one `Handler`: MPRIS over
@@ -535,7 +646,11 @@ rather than renaming them.
level rather than writing through to the volume, so it cannot level rather than writing through to the volume, so it cannot
accumulate and nothing persists or emits a level the user did not accumulate and nothing persists or emits a level the user did not
choose — and it only ever fires below API 26, where the framework choose — and it only ever fires below API 26, where the framework
does not already duck the app itself. does not already duck the app itself. On that platform "the user's
level" is a constant, since #64 pins it at maximum and refuses every
way to move it; the duck is the one thing that still may, and it
works unchanged because it was always an offset applied *to* that
level rather than a write of it.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows). - `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below. - `explore` — Catalog search and browse over `explore_index`. See below.
Its **shelves** (`shelves.go`) are the page Explore shows before Its **shelves** (`shelves.go`) are the page Explore shows before
@@ -945,13 +1060,167 @@ change at all.
Two rules hold it up. The **first** navigation *replaces* the launch Two rules hold it up. The **first** navigation *replaces* the launch
entry rather than pushing one, or every launch costs a back press before entry rather than pushing one, or every launch costs a back press before
the app will close. And the in-app back buttons (`navigate-back`, fired the app will close. **There are two launch navigations**, which is what
defeated that rule for five phases: the eager `navigate → home` at the
foot of `index.ts` and the configured page `GetDefaultPage()` resolves
to later. Only the first replaced, so a fresh session was already one
entry deep, the first back press replayed home over home, and on Android
`canGoBack()` was true so the press that should have exited the app did
nothing (#142). The landing-page navigation carries `_replace`, honoured
only while still at index 0 — past that the user has navigated during
the backend call, and a slow answer must not overwrite an entry they
made. And the in-app back buttons (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()` by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press button and the phone's gesture come to disagree about what one press
means. means.
**And there is one statement of which view is active**, for the same
reason: `popstate` calls `handleNavigate()` directly and dispatches no
`navigate`, so the two nav components — which learned the active view
from that event — kept highlighting the view the user had just *left*.
`store/active-view-store.ts` is the shell saying where the user is, and
both navs read it through `ActiveViewController` rather than holding an
`activeView` of their own.
Four things about it are load-bearing.
**"Please go to X" and "the active view is now X" are different
statements**, and only the first existed — dispatched from 28 call
sites across 18 files. A re-dispatch from inside `handleNavigate` is
not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" drawer
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, the drawer opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
from stays lit. `app-sidebar` did that by accident (it guarded on
`navItems.some(...)`, so an unmatched name left its highlight alone)
and `bottom-nav` had no such guard and so lit *nothing* — which is why
one looked right and the other looked broken on the same screen.
Whether a view is primary is the shell's fact: `view in VIEW_TAGS` is
passed to `setView`, never re-derived, because a second copy of that
list is a second thing to forget.
**Nothing is lit until the shell has navigated.** The store starts
empty rather than defaulting to `home`, which is what `app-sidebar`'s
field used to do to match the landing view — a default that is correct
only while `GetDefaultPage()` agrees with it.
**Back and forward are chrome, and the depth is the shell's own
count.** `<nav-history>` in the top bar is #6: the stack was always
global — every navigation is an entry and `popstate` restores any of
them in either direction — so what was missing was an affordance, since
the only way back was a detail view's own button, which leaves the
screen with the view it belongs to. The buttons dispatch
`navigate-back` / `navigate-forward` and the shell owns both guards,
for the reason the old `navStack` was deleted: a second caller reaching
for `history` is how two stacks come to disagree.
Three things about it are load-bearing. **Forward is not back
negated**, so the single `pushedEntries` counter could not express it —
`popstate` carries no direction and fires identically both ways, so a
counter decremented on every pop reads a forward as a second back. Each
entry carries its index (`yjIdx`) and the shell keeps the current one
and a high-water mark; that also survives a jump of more than one,
which `history.go(-n)` and a long-press on a browser's back button both
produce. **A control that cannot act is `disabled` here**, which is the
documented exception to `library-status-indicator`'s rule: the two are
a pair whose positions the user learns, and hiding one moves the other
under the cursor. And **it stands down below 900px** — the top bar is
what runs out of room first below that (it already overflows 600px by
11px, #143), and nothing becomes unreachable: `nav.back` / `nav.forward`
(`Alt+Left` / `Alt+Right`, the browser's own combination, and clear of
the bare arrows that seek) are global at every width, and the phone has
the platform's gesture.
The assertion is `aria-current="page"`, in
`e2e/specs/back-navigation.spec.ts`. That file existed throughout the
bug, covered exactly these journeys, and asserted only
`data-active-view` — the shell's own bookkeeping, which was right the
whole way through — so it was green on the broken build. Same trap as
`layout-overflow.spec.ts` and `page-header`: a spec named for the
behaviour, measuring the plumbing.
**Which destinations exist is configuration, and hiding one takes away
the nav item and nothing else.** Eleven sidebar entries is more than
most libraries need (#25), so each is toggleable from Settings →
Navigation, Autotag is off until asked for, and Downloads is absent
until there is a client to download with — a destination for a feature
that cannot work is worse than none. `navigate` still resolves a hidden
view, which is not a nicety: detail views navigate into these and the
launch page is one of them. Nothing needed a special case for the
highlight either, because the paragraph above moved that onto
`active-view-store`: the sidebar asks `isActive(id)` per *rendered*
item, so a hidden view lights nothing exactly as a detail view does.
Five things about it are load-bearing.
**The stored shape is a map keyed by view id, and an absent key means
that view's own default** (`backend/config.Views`). That is what makes
this need no migration in either direction, and it is the polarity rule
`AllowMeteredCatalogDownload` states: the zero value is the intended
answer. A `HiddenViews []string` cannot express "Autotag off by
default" at all — its zero value is *hide nothing* — and a struct with
a boolean per view turns a view that later stops existing into stored
garbage. Here an unknown key is dropped on load and a view added later
gets its own default rather than being invisible or forcibly visible.
It is also what makes #73's `#25 → #27` order safe rather than
backwards: when Jobs folds into Settings, `jobs = true` in somebody's
config is a key nothing asks about.
**Two states the user could not get out of are refused, in the config
and not in the checkbox.** Settings is never hideable and the launch
page is not hideable while it is the launch page. `config.toml` is
hand-editable, so a disabled checkbox is the affordance and
`SetViewVisible` is the rule — an app that can be locked out of its own
Settings by a typo in TOML is a support problem nobody can debug
remotely. On *load* the launch page is instead un-hidden rather than
refused: there is nobody to tell, and the honest reading of "my launch
page is Autotag" is that this user wants Autotag, not that their launch
page should be silently reset to something they did not choose.
**Downloads is gated at the nav and not in the config**, on
`downloadStore.available`, so switching it on in Settings still means
what it says once a client exists and the tab appears without a restart
(#37's rule). `available` is false until the providers have loaded,
which makes the item *appear* on a fresh launch rather than appearing
and then vanishing.
**The tab bar honours the toggles too, and the reason is local rather
than a general rule about phones.** `PHONE_COLUMN_IDS` is the precedent
for "what a phone shows is a different question", and it would apply —
except that `bottom-nav`'s "More" opens the *same* `<app-sidebar>`,
which filters, so an unfiltered bar would contradict its own drawer one
tap away. Which four tabs is still plan 016's committed subset; this
only removes from it, and "More" is never filtered because it is how
everything else stays reachable.
**A retired destination is the one shape this does not make free.** An
absent visibility key takes its default and an unknown one is dropped,
but `DefaultPage` is a *value*: a launch page naming a view that no
longer exists fails validation, and on the load path that means the app
refuses to start for whoever had it selected. `RetiredViews` is that
list, and `ApplyDefaults` treats a retired name as a zero value while
an unknown-but-not-retired one still errors — a typo is worth being
told about. #27 retiring `jobs` is its first entry.
**The list of destinations is `services/view-meta.ts`**, on
`shortcut-meta.ts`'s pattern, because #25 gave it a second reader:
Settings renders a toggle per view and needs the same labels in the
same order. Which views exist and what an unconfigured install shows is
Go's (`backend/config.Views`, which `DefaultPage`'s validation reads
too, so the launchable set is not a second list); how they are *drawn*
is the frontend's, beside the rest of the icon vocabulary. The binding
returns the **resolved** map for every view, so the frontend holds no
copy of the defaults — which would be the copy that shipped in the
binary rather than the one being edited.
**A primary view is cached, not unmounted.** `index.ts` keeps every **A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so is what preserves `scrollTop` across navigation — so
@@ -1294,8 +1563,8 @@ still permits *programmatic* scrolling, so a probe that sets
**Below 600px it reflows instead, and that is the phone.** The sideways **Below 600px it reflows instead, and that is the phone.** The sideways
scroll above was the concession available while the shell had one scroll above was the concession available while the shell had one
layout; plan 016 B2 gives it a second. Under 600px the grid drops its layout; plan 016 B2 gives it a second. Under 600px the grid drops its
sidebar column, `<bottom-nav>` takes over as the primary navigation, sidebar column *and* (since #57) its top-bar row, `<bottom-nav>` takes
the header's controls shrink or stand down, and the shell measures over as the primary navigation, and the shell measures
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
all along. 600 rather than the sidebar's 900 because 900 is a laptop: all along. 600 rather than the sidebar's 900 because 900 is a laptop:
@@ -1328,6 +1597,355 @@ is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind else — Settings included, because a phone still needs it — is behind
"More". "More".
**There are three supported size bands, and the queue is part of the
promise.** Plan 018 (#24) wrote them down: **Phone** below 600 (bottom
nav, reflows, fits 320px exactly), **Compact** 600899 (icon sidebar),
**Desktop** from 900 (labelled sidebar) — plus one sentence across all
three, *no action is ever unreachable at any supported size*. The bands
themselves already existed; what was new is that they are a promise and
that the queue panel is inside it.
**And below 600px there is no top bar at all** (#57). The row is gone
from the phone's grid template — not the header hidden, the row deleted
— which is 3.25em of a 439 CSS px viewport, the single biggest vertical
win the reference device has to give. Each of its five children has
somewhere else to be there: `nav-history` is the platform's own back
gesture (already gone from 899 down), the job indicator is `<job-band>`
(#62, which is why this was blocked on it), the search box is a
`wa-dialog` opened from the view's own header, the library filter is
Settings → Libraries (#148), and the wordmark stays exactly where it is.
Three things about it 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 `heading` is `''`, 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 every time and report success about
a 1px box. And **`top-bar-fit.spec.ts` keeps 390 in its list and asserts
the stronger property there**: "nothing hangs out of the bar" is
trivially true of a bar with no row, and would have passed on a build
that merely broke it, so what that width asks now is that the content
starts where the row above it ends.
**Above 600px the top bar decides what it can afford, and what it gives
up is never an action.** Its five children do not fit at the bottom of
the Compact band: the bar was 611px inside a 600px viewport idle and **862px while
a scan ran**, because `job-indicator` is `hidden` when idle and 235px
wide showing a real library's scan title (#143). So `services/
top-bar-fit.ts` is `page-header`'s treatment one bar up — a
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits.
Five things about it are load-bearing.
**It is measured rather than breakpointed for a reason specific to this
bar**: three of its five children are as wide as their *content* — the
library filter is a `<select>` sized by the longest library name, the
indicator by the running job's title, the search box by its view-scoped
placeholder — so any width picked is right for one library, one job and
one view. Swept with a long-titled scan staged, the bar overflowed at
**every** width from 600 to 899 *and* at 900 where `nav-history`
appears, while 899 fits; a breakpoint fixing "600 to 610" would have
fixed whichever case happened to be idle when it was measured.
**What yields is decided by the promise above, which rules out the two
cheapest answers.** Hiding the library filter takes away an action —
`library-filter` was the only control in the app that called
`setSelectedLibrary` — so it trades this promise for the same promise.
That is #148, and #57 fixed it by giving the selection a *second
placement* rather than a second definition: the same component, in
Settings → Libraries under a "Showing" label, at every width. A
phone-only copy was the obvious cheaper answer and is the fault, not 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.
Collapsing the search box to an icon is what #57 wanted and #57 was
blocked behind #62, so building it here would have been building it
without the thing that blocked it. The two that yield are the two that are **not** actions: the wordmark, which the
window's own title bar repeats and which #48 wants down to "YJ" at
every width anyway, and then the job indicator's *label*, leaving the
ring — which is not a new judgement, since the component already drops
it below 600px and its `sr-only` live region is what announces the
state either way.
**The wordmark yields its width, not its existence.** The collapsed
rule is visually-hidden rather than `display: none`, because that `h1`
is the document's top-level heading as well as the brand.
**"Fits" is the children against the content box, and `scrollWidth`
cannot express it.** `scrollWidth` counts a box's left padding and not
its right, so with 2em gutters it under-reports by 32px: the first fix
read `700/700` — a perfect fit — with the indicator sitting in the
whole right gutter. Same family as #69's title trap, and found only
because `top-bar-fit.spec.ts` measures **per child**, which is what
`layout-overflow.spec.ts` cannot do and why that spec was green
throughout the defect.
And **the bar does not resize when a job starts**, which is the case the
whole thing is for — a ResizeObserver on the header alone never fires,
so every element child is observed too.
**The bottom bar is three columns whose outer two are the same width,
and that is what "centred" means.** It was `320px 1fr auto`, so the
transport sat in the middle of the space the metadata and the queue
button did not use — its centre was ~140px right of the window's at
every size (#23). The outer tracks are now the same expression, so the
middle one is centred by construction rather than by arithmetic that
has to be redone whenever a control joins the bar.
Four things about it are load-bearing.
**The side width is the metadata's, capped at a quarter of the bar**,
and the cap is not tidiness — it was measured as a regression first.
Reserving the full `--now-playing-width` on *both* sides costs the
transport twice: at 800px the outer pair wanted 640 of 800 and the seek
bar's track fell from **257px to 61px**, and to 0 at 200% text. The
control you drag was being squeezed to centre the buttons above it.
With the cap it is 246px at 800, which is parity with the uncentred
layout.
**The cap is a `min()` rather than a breakpoint** because
`--now-playing-width` is *user state* — the metadata panel has a drag
handle — and the same reasoning the queue panel's overlay mode uses
applies: a rule that assumed the default 320 would be wrong by whatever
the user dragged. Tying both sides to that variable is also what keeps
the handle meaningful; a plain `1fr … 1fr` would centre the transport
just as well and silently make dragging a no-op.
**The volume moved out of `audio-player` and into the bar** (#42),
because the transport column has to hold the transport and nothing
else or "centred" means centred with a slider bolted to one side. It
lives in `.bar-end` with the queue button — one cell, not two columns,
since the centring compares *columns* and a separate volume track would
make the outer pair unequal by whatever the slider measures.
And **the slider is inline by default, with the popup as a setting**
whose stored flag names the *popup*: `backend/config`'s polarity rule,
where the zero value has to be the intended answer, so an existing
`config.toml` with no key gets the new default without a migration.
Inline, the icon becomes the mute toggle and is named after that action
rather than after the state, because with the slider beside it there is
nothing left to disclose. The bar's copy stands down below 600px, which
is about *room*: five controls and a slider do not fit a 360px bar, and
`now-playing-view` is where seeking and volume go on a phone.
**Whether there is a volume to control at all is a different question,
and it is asked of the player** (#64). On Android the hardware keys are
the volume control and the framework mixes our stream against the
device level, so `player`'s own level is pinned at maximum, `SetVolume`
/ `ChangeVolume` / `MuteToggle` are refused, and `volume-control`
renders `nothing` — in both of its mount points, at every width.
`mediacontrols`' Android handler implementing no volume callback is the
same fact one layer down.
Five things about it are load-bearing.
**It could not be a width, and that is not a preference.** 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 bottom bar's slider over a pinned level — a control that
cannot act, on exactly the platform the rule exists for, which
`library-status-indicator` already settled is worse than none. The
same rule is wrong in the other direction below 600px, where a narrow
desktop window has no hardware keys to fall back on.
**The predicate is named after the capability, not the platform.**
`SystemOwnsVolume` is what the frontend asks; `platformOwnsVolume` is
the one build-tagged constant behind it, in two files that declare
nothing else. That is `mediacontrols`' split with
`androidpayload.go`'s reasoning: a tagged file is compiled by nothing
`make lint` or `make test` runs, so everything decidable off a phone is
decided against `Player.systemVolume`, a field a test sets either way.
The frontend's absent branch is therefore testable in the component
tier with a stubbed binding, and the constant itself is covered by a
source sweep plus, once, a real arm64 device answering `true` — which
is the only tier that compiles the `android` file at all.
**Mute goes with it, 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 the user could not get out of.
**Nothing persists a level nobody chose.** The maximum the player runs
at is synthetic, so `restoreStateLocked` *remembers* the stored volume
instead of applying it and `saveState` writes that same value back.
The alternative — a second query that omits the column — buys nothing
and is a second write path to keep in step.
**And ducking is untouched, which is what makes the pin safe.**
`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. It is the only thing that may move
the output on such a platform, and it is the one volume-shaped path
that is not refused.
One thing to know before anyone offers to test it on a phone: **the
duck is unreachable above API 25.** `WailsForegroundService` builds its
`AudioFocusRequest` without `setWillPauseWhenDucked` from Oreo, so the
framework attenuates us itself and never sends
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` — a device confirms it by logging
`requestAudioFocus() … flags=0x0`. `minSdk` is 21, so this is live code
rather than dead, on Android 5.0 to 7.1 and nowhere else.
**And below 600px that bar carries three controls, not five** (#59).
Shuffle, repeat and the queue button leave it; what is left is art,
title/artist, favourite, and prev/play/next. `player-controls` is one
component in two places and **the context is a property rather than a
media query**, which is the exception to the rule two paragraphs down:
on a phone the bar wants three controls and `now-playing-view` wants
five, larger still, *at the same viewport* — so the host states the
context and the viewport states the size band, and neither alone can
express it. Sizes come from `--yj-control-*` custom properties set per
context; play/pause alone goes above the 44px floor, because a row of
identical squares says every action is equally likely and that is not
true of play. Measured before #56: every one of them was **33×21px**,
and the mini bar's favourite was **18×14**, the smallest control in the
app.
Four things about it are load-bearing.
**The phone draws three buttons rather than hiding two**, from
`matchMedia``job-band`'s pattern, and the rule that a decision about
whether an element *exists* is not a stylesheet's to make. A
`display: none` control is still in the shadow root and still something
a positional query finds, so "the phone has three controls" would have
been true of the pixels and false of the element.
**Removing a control is only allowed because it is still reachable.**
Plan 018's matrix promises no action is unreachable at any supported
size, and all three are on `now-playing-view`, one tap away through the
mini player's art. That promise is what `phone-transport.spec.ts`
asserts — it walks the route — rather than counting buttons.
**So the route to Now Playing must not depend on what is playing**, and
it did. `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 — which, once the queue button left the
bar, made the *queue* unreachable. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into.
**The desktop bar is untouched and a spec says so with a literal.**
Both issues are `Platform/Android`. The trap is that 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 button from 33×21 to 36×24, silently. The sizes are
asserted as `'33x21'` rather than as a range, because the regression
was three pixels.
**900 is the worst desktop width, not the 800×600 minimum.** The
sidebar collapses to icons *below* 900, so the main panel is 843px at
899 and 700px at 900 — the narrowest content area any desktop width
produces is at the top of the Compact band, not at the enforced floor.
Every viewport list that stopped at "the minimum" was therefore missing
its own worst case, which is why `layout-overflow.spec.ts` carries 900
now. And **both reasons in `MinWidth`'s comment had expired** — the
subtitle is `display: none` from 899 down and the sidebar host scrolls
(`overflow-y: auto`; at 600×460 its `scrollHeight` is 434 against a
332px client) — so 800×600 is a *comfort* floor for desktop chrome and
not a correctness one. Below it the phone layout takes over, which is
also why a very small window reflows rather than becoming a
mini-player: **#12 is a second always-on-top window, not a mode of this
one**, and making it a mode would discard navigation state on a resize
and put the process-level MPRIS question on a path a drag can trigger.
**The queue panel is a column only while the content can spare the
width, and that cannot be a media query.** In flow the host is
`flex-shrink: 0`, so an open queue is paid for by the main panel: it
left 379px at 900×600 (with all three of the Playlists header's actions
clipped), 69px at 390, and **0px** at 320 — the content was not
degraded but gone. It goes to an overlay with a scrim when
`available - panelWidth < 480`, where `available` is
`.content-area`'s width and therefore already accounts for the
sidebar's collapse.
Four things about it are load-bearing. **The mode is computed, not
breakpointed**, because the panel's width is user state — drag-resizable
200500px and persisted — so a viewport breakpoint silently assumes the
default 320 and is wrong by up to 180px in the direction that hurts;
widening the panel at a fixed window size must flip it, and
`queue-overlay-mode.test.ts` is written around exactly that. **480 is a
judgement and says so**: there is no cliff to derive it from (the track
list rescales continuously, 213px to 124px columns with no row
overflow), so it is anchored to keep the default 1100px window inline
and put every measured-broken case on the overlay side. **The scrim
covers the content area only** — not the sidebar or the transport —
because the queue is not modal, and it is subtle on a dark ramp by
arithmetic rather than by accident (33,37,41 → 18,20,23). And **the
overlay is a presentation, not a fork**: #55 asks for one component
with two mount points, so the roving tab stop, Alt+Arrow reorder, drag
reorder, selection semantics and `virtualizer.requestUpdate()` all come
along untouched. Escape closes it and returns focus, and is attached
only while the overlay is up — it is a dismissal, not a shortcut, which
is why it is not a panel-scoped binding.
**And an overlaid queue is a place, which is the whole of #55.** The
pixels were already right: measured at the reference device's 424×439,
the overlaid panel is 424×318 — `.main-panel`'s rect exactly — so a
`DETAIL_LOADERS` mount would draw the same rectangle in the same spot.
What was missing was the navigation model, and the defect was one
measurement: opening the queue on Artists and pressing back moved the
page *underneath* to Albums and left the queue up. So opening an
**overlay** queue dispatches `navigate {view: 'queue'}` and opening a
**column** sets the attribute as it always did — `utils/open-queue.ts`
is that one decision, and both routes end at the same `open` attribute
on the same element.
Five things about it are load-bearing.
**The queue is a screen exactly while it is an overlay**, which is the
rule above rather than a second one: a column is a thing the user
docked, so back must not undock it and a navigation must not take it
away, while an overlay is covering the content and has to answer the
platform's gesture. That also inherits the *computed, not
breakpointed* property for free — the panel is drag-resizable, so a
viewport breakpoint would be wrong by up to 180px.
**It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`**, because there is
nothing to mount; the panel is already in the document. That is not
tidiness. `.main-panel > *` is paint-contained under a `.main-panel`
that is, and `contain: paint` clips the `position: fixed` a `wa-popup`
falls back to on the reference device's Chrome 113 (#60) — so the
detail-view mount asked for in #55's Direction would have broken
`queue-panel`'s working context menu on the one device the issue is
about. Measured: the panel's ancestry is `layout style` all the way to
`body`; a view inside the main panel is `content` under `content`.
**No tier here can see that consequence** — CI's Chromium and WebKit
both have the Popover API — so `queue-as-a-screen.spec.ts` asserts the
*mechanism*, that the panel is not under a paint-contained ancestor.
**A navigation to `queue` deliberately 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. It publishes itself through `activeViewStore`
with `isPrimary: false`, so the tab it was opened from stays lit —
the same rule a detail view gets.
**The entry is unwound from the panel's `open` attribute**, in the
mutation observer `index.ts` already ran for `aria-expanded`, rather
than at each of the four ways out. Escape, the scrim, the close button
and the toggle all take that route, and a fifth added later gets it
free. Without it the entry is orphaned and the *next* back press is the
one that closes the queue — the reported defect moved one press later,
which looks exactly like a press that did nothing.
**And the way out is 44px on a phone.** With the panel spanning the
whole width the scrim has no uncovered pixels at all, so the close
button is the only pointer route out of a full-screen surface; it was
**25×21px**.
What this does **not** fix is `page-header` overflowing on its own:
at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with
the queue *closed*. That is #69, and it cannot be fixed in
`page-header` alone — actions arrive through `<slot name="actions">` as
arbitrary light-DOM markup with their own handlers, so collapsing them
into a "More actions" menu needs an actions *API* (data, not markup)
across all three hosts that slot them.
**The phone section of `index.css` is last on purpose.** A media query **The phone section of `index.css` is last on purpose.** A media query
adds no specificity, so a `@media (max-width: 599px)` block placed adds no specificity, so a `@media (max-width: 599px)` block placed
above the plain rules it overrides loses to them — which is how phase 1 above the plain rules it overrides loses to them — which is how phase 1
@@ -1658,6 +2276,59 @@ not about plumbing — it says rows may be missing from the page
altogether, which nothing on screen can show. (`explore-artist-details` altogether, which nothing on screen can show. (`explore-artist-details`
still uses `loading`; it has no equivalent per-row signal.) still uses `loading`; it has no equivalent per-row signal.)
**And that treatment is the app's, not the page's.**
`utils/ownership.ts` is the rule written once, because it was written
at eight call sites and so none of them had the whole of it: Explore's
cards, `top-results-row` and the artist page's three card shapes all
mixed owned and unowned with a small badge as the only difference, and
the badge on the *owned* ones was a green tick — the mark on the common
case this tracklist removed. Owned is plain and draws no badge at all;
unowned is dimmed, says so in its accessible name, and keeps its
request affordance; a partly-held album says how partly.
Four things about it are load-bearing.
**Ownership is `localId`, and `inLibrary` is deliberately not
consulted.** The album page answers with `filePaths`, a real file per
displayed track, and a card grid cannot afford that — but it does not
need to, because `explore_index.local_*_id` is built by
`collectLibraryEntities` from queries that every one join `audio_files`
and cleared by `pruneStaleLocalCrossReferences`, whose existence test
is a file test in all three cases. That is the same "ownership is a
file" rule computed once per scan instead of once per screenful.
`in_library` is written by the same pass, so the two agree in a healthy
database, but it is a one-way ratchet
(`MAX(in_library, excluded.in_library)`) whose only clearing pass is
gated on a non-null local id: it cannot be un-set on its own (#118).
One is a fact with an owner; the other is a flag that happens to agree.
Both `explore-view` and `explore-artist-details` additionally kept a
`libraryMBIDs` set that accumulated every MBID ever seen with the flag
and cleared it never, in views that never unmount; both are gone.
**The two answers used to sit on one card.**
`renderReleaseMenuItems` gates Play on `release.localId > 0` while the
badge used `inLibrary`, so an album with the flag and no local row drew
a tick saying it was in your library, offered no Play, and — the
request item being gated on *not* owned — offered no way to ask for it
either. Any new surface that asks the question twice will reproduce it.
**`aria-disabled` goes on rows and not on cards.** An unowned *row*
cannot be activated; an unowned *card* still navigates to the catalog
page for it, which is a perfectly good thing to do with something you
do not own. The accessible name carries the state either way, which is
why it is one helper and not a class.
**The count is batched, not looked up.** `store/completeness-store.ts`
is `credit-store` one question over: `request()` is per-card and
coalesces a screenful into one `GetAlbumsCompleteness`, absence is
cached as an answer (or the albums with no totals re-ask forever), and
the whole cache is dropped on a scan, a retag or a removal rather than
aged. `library-status.ts`'s `albumBadgeFor` is where that meets
`Known`: a total that was never declared is a plain `in-library`, never
a ring at 0%. One consequence in the badge itself — a `partial` badge
is *actionable*, and a control named after its action alone dropped the
count from the one state the ring exists for, so its name is both.
**A partly-owned album draws the release, not the part.** Once the tags **A partly-owned album draws the release, not the part.** Once the tags
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
with three dimmed, rather than the nine on disk — the missing tracks with three dimmed, rather than the nine on disk — the missing tracks
@@ -1865,6 +2536,96 @@ that corrects itself a moment later is worse than saying nothing. And
the field, the direction and their persistence, so the control cannot the field, the direction and their persistence, so the control cannot
disagree with the list. disagree with the list.
**And an action is data, on that same rule: the header decides what
fits, the host decides what happens.** Playlists slotted three buttons
totalling 390px into a header that gets 700px at 900×600, so "New Smart
Playlist" rendered **114 of its 162px** with the queue closed — and on a
phone none of them could be reached at all, which is what #69 reported.
A host passes `PageAction[]` (`{id, label, icon, onSelect, priority,
drop?}`) and `page-header` renders each one as a button or as an item in
one "More actions" menu.
**It could not have been a rule added in one place**, and that is a fact
about the API rather than an effort estimate: actions used to arrive
through `<slot name="actions">` as arbitrary light-DOM markup, and a
component cannot move another component's light-DOM children into a
dropdown and keep their behaviour — there is nothing generic in markup
to render as a menu item. The slot survives for markup a data list
cannot express, at the stated cost that **a slotted action does not
collapse** and must therefore fit at 800×600.
Six things about it are load-bearing:
- **The fit is measured, never breakpointed.** A ResizeObserver drives
it, and each pass starts from *all visible* and hides the
lowest-priority action until it fits — so the collapsed set is a pure
function of the current width rather than of how the window got
there. A rule that only ever added to the set would never give a
button back, and one that adjusted by a step would need a hysteresis
band to stop it oscillating on the pixel where a button exactly fits.
- **"Fits" means nothing is clipped, which is not the same as the
header not overflowing.** The title can ellipsis, and the moment it
can it absorbs the pressure: `scrollWidth` reports a header that fits
perfectly while the heading reads "Playlis…". That is this bug moved
from the button to the title, invisible to the same measurement that
missed it the first time — so the heading's own truncation counts as
not fitting, and an action is collapsed before the title gives way.
Below that, at 320px, the title *is* what yields: the navigation also
says which page you are on, and an action has nowhere else to be said.
- **The measurement flips `hidden` on the rendered nodes rather than
re-rendering between steps.** Reading `scrollWidth` forces layout,
which is the point; awaiting a Lit update between steps instead lets
the intermediate all-visible state paint, so the fix would flash the
overflow it exists to prevent.
- **Priority is what a *capability* costs, not what a button is worth.**
New Playlist is highest because it is the **drop target** and a closed
menu cannot be one; that is also why `PageAction.drop` carries the
host's own `dragover`/`dragleave`/`drop` handlers rather than the
header owning a notion of dropping, and why the affordance is simply
absent from the overflow rather than approximated there.
- **`aria-controls` names a panel that is always in the DOM** —
`config-section`'s rule, and `wa-popup` hides it when inactive — and
the keyboard model is `MenuKeyboard`, shared with every other menu in
the app so this is not a second one.
- **It is checked per button, because `layout-overflow.spec.ts` cannot
see this.** That spec asserts the *shell* needs no sideways
scrolling and passed on the broken build; clipping *inside* a
component is invisible to it, which is exactly why the defect
survived a spec named for it.
`e2e/specs/header-action-overflow.spec.ts` measures each button
against its header at 900×600, 800×600, 390×780 and 320×600, and
asserts buttons **plus** menu account for every declared action —
without that half it would pass vacuously on a build that renders no
actions at all.
**The count is the last thing to yield, and only at 320px.** Four
things compete for that row and three of them cannot go: the title
yields first and is allowed to ellipsis away entirely, because the
navigation also says which page you are on; the sort control and the
actions are each the only place they are said, which is what the
overflow menu exists for. That leaves the count, which is the one
purely informational item there — 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, since below 600px this header also carries
the phone's search button: measured on Playlists at 320px, 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. 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.
One thing it deliberately does **not** grow is a phone mode for the
actions. `PHONE_COLUMN_IDS` is the precedent for "what is drawn and
what can be sorted are different questions", but it exists because the
track list's columns cannot be derived from a width; these can, and a
second declaration of what a phone shows is a second thing to keep in
step. What the header *does* state at phone width is one word: below
600px the sort control's "Sort:" label is visually hidden — 172px of a
320px header for a label the adjacent direction arrow implies — and it
stays in the accessibility tree, because it is the select's accessible
name and hiding it outright is `config-field`'s bug one component over.
**The header search box is view-scoped, and now says so.** It sits in **The header search box is view-scoped, and now says so.** It sits in
the app header and reads as global; typing `tide` on Playlists answered the app header and reads as global; typing `tide` on Playlists answered
"No playlists match your search" with three *Tideline* tracks in the "No playlists match your search" with three *Tideline* tracks in the
@@ -1881,6 +2642,54 @@ term belongs in that map**, detail views included —
placeholder saying there was nothing to search here, because its placeholder saying there was nothing to search here, because its
sibling was in the map and it was not. sibling was in the map and it was not.
**On a phone the box is a modal, and the map is what decides who gets
one** (#57). There is no header to hold it below 600px, so
`<search-trigger>` is a button in the row that already says which page
you are on and `<search-dialog>` is where the box goes — and both ask
`searchStore.isSearchableView()` rather than being told, which is the
whole reason the trigger is an element and not a `PageAction`. Seven
hosts each declaring a search action would be a second list of
searchable views, and putting the decision inside `page-header` would
be the phone mode for actions that component documents its refusal to
grow.
Four things about it are load-bearing.
**It is a `wa-dialog`, and that is a mechanism rather than a taste.**
#60 read out of the Web Awesome source that `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. `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 popup would be top-layered and correct and a spec
asserting "not clipped" would pass on the broken build. The component
tier asserts the *mechanism* instead: that there is a native `<dialog>`
in the tree.
**It carries the real `<search-bar>`**, not a second input, which is
what keeps one debounce, one clear button and one view-scoped
placeholder. `--yj-search-max-width` is the one thing the modal changes
about it: 360px is a cap for a header, not for a control that has the
whole of a 424px screen.
**The results are the page, not a list in the modal.** The term is
view-scoped and the view behind already filters on it and says
"Showing tracks matching …", so Enter closes and hands the screen back.
Rendering results in the dialog would be a second implementation of
every view's filtering, and one that could not offer the row actions
the view does.
**Escape closes and keeps the term.** `search-bar`'s own input treats
Escape as *clear the search*, which is right in a header where the box
is on screen either way; in a modal it would mean dismissing the search
surface silently discarded the search. The dialog takes the key in the
capture phase on its own host, which is the only listener that runs
before the input inside `search-bar`'s shadow root.
**The window's minimum is measured, not aspirational.** `MinWidth`/ **The window's minimum is measured, not aspirational.** `MinWidth`/
`MinHeight` are 800×600 because that is where the shell was checked to `MinHeight` are 800×600 because that is where the shell was checked to
still work: below ~780 the header subtitle wraps and pushes the title still work: below ~780 the header subtitle wraps and pushes the title
@@ -2254,6 +3063,23 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
mistyped `feat` ships a minor version. `make release-dry` answers "what mistyped `feat` ships a minor version. `make release-dry` answers "what
would this merge release" without pushing. would this merge release" without pushing.
**The analyzer reads the type and ignores the scope, so a CI-only change
is `ci:` and never `fix(ci):`.** The scope is decoration; `fix` is a
patch whatever is in the brackets. Two commits touching nothing but
`.gitea/workflows/unclaim.yml` were written `fix(ci):` and cut `v0.2.1`
and `v0.2.2` — real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change. They were left in place
rather than deleted, because a version that vanishes is worse for
whoever pulled it than one that turns out to be empty.
**The blast radius is bigger than the version number**, which is what
makes this worth a paragraph. A merge to `main` starts two workflows;
if `release.yml` then pushes a tag, that tag push starts **four more**
(`arch-package`, `homebrew-formula`, `android-apk`, `desktop-assets`) —
on a runner with capacity 1, where the APK build alone is tens of
minutes. `make release-dry` before merging is how you find out, and it
is cheaper than every one of those.
**`@semantic-release/github` is not in that config and must not be.** **`@semantic-release/github` is not in that config and must not be.**
Gitea's API is `/api/v1` and is not GitHub's surface, so Gitea's API is `/api/v1` and is not GitHub's surface, so
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one `@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
+28
View File
@@ -17,6 +17,34 @@ const (
RecommendationStrong Recommendation = "strong" RecommendationStrong Recommendation = "strong"
) )
// ConfidentTier is the tier at which this package considers a match
// good enough to act on without being asked to look.
//
// It exists as a name rather than as `== RecommendationStrong` at
// each call site because two features read it and they must not
// disagree about what "high confidence" means: the album page tells
// the user unprompted that the autotagger has a match (#28), and
// strict auto-accept will rewrite the files without asking (#90).
// A page that says "we are sure" about something the auto-accept
// pass would decline is the app contradicting itself.
//
// What the two do *not* share is everything else. Surfacing a match
// is a suggestion with a confirm dialog behind it; auto-accept is an
// irreversible on-disk rewrite, and #90 gates it on further
// conditions this tier cannot express — exact track count, every
// title matching, lengths within a couple of seconds, no cover
// replacement, no MBID conflict. So this is the floor both stand on,
// not the whole of either test.
const ConfidentTier = RecommendationStrong
// Confident reports whether a tier clears ConfidentTier.
//
// A comparison rather than an equality, so adding a tier above
// "strong" later does not silently stop qualifying.
func Confident(r Recommendation) bool {
return recommendationRank(r) >= recommendationRank(ConfidentTier)
}
const ( const (
// Absolute score tiers. // Absolute score tiers.
strongScoreThresh = 0.90 strongScoreThresh = 0.90
+31
View File
@@ -168,3 +168,34 @@ func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
t.Errorf("different-title rival: Recommend = %q, want medium", got) t.Errorf("different-title rival: Recommend = %q, want medium", got)
} }
} }
// The tier both features stand on is one name, checked here rather
// than assumed at two call sites.
//
// #28 renders "we have a match for this album" on the album page and
// #90 will rewrite files without asking; a page that claims confidence
// the auto-accept pass would decline is the app contradicting itself.
// What they do not share is everything else — auto-accept adds gates
// this tier cannot express — so this pins the floor, not the whole of
// either test.
func TestConfidentIsTheOneSharedFloor(t *testing.T) {
t.Parallel()
if ConfidentTier != RecommendationStrong {
t.Errorf("ConfidentTier = %q, want strong", ConfidentTier)
}
for _, tc := range []struct {
rec Recommendation
want bool
}{
{RecommendationNone, false},
{RecommendationLow, false},
{RecommendationMedium, false},
{RecommendationStrong, true},
} {
if got := Confident(tc.rec); got != tc.want {
t.Errorf("Confident(%q) = %v, want %v", tc.rec, got, tc.want)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
package autotagservice
import (
"database/sql"
"fmt"
"yellowjacket/backend/autotag"
)
// AlbumMatchView is "the autotagger already has a confident match for
// the album you are looking at".
//
// It is deliberately not a score. The album page renders a suggestion,
// and a suggestion has to be actionable: which release, what it is
// called, and whether acting on it here would do the whole album or
// only part of it.
type AlbumMatchView struct {
// GroupKey is the tagging group the actions operate on.
GroupKey string `json:"groupKey"`
// Recommendation is the tier, as a string, for a caller that
// wants to render the strength rather than trust the filter.
Recommendation string `json:"recommendation"`
// Score is the top candidate's raw score, 0..1.
Score float64 `json:"score"`
// ReleaseMBID is the release Apply would write.
ReleaseMBID string `json:"releaseMbid"`
// Title and ArtistCredit name that release, so the banner can say
// what it is offering rather than "a match".
Title string `json:"title"`
ArtistCredit string `json:"artistCredit"`
// TrackCount is the group's local track count.
TrackCount int64 `json:"trackCount"`
// GroupCount is how many tagging groups this album spans.
//
// More than one means a multi-disc album (one group per disc), and
// it is the reason this is a field rather than an implementation
// detail: applying "the album" from a single button would retag
// one disc of three and leave the folder holding a mix of old and
// new tags. The caller offers review instead.
GroupCount int `json:"groupCount"`
}
// MatchForAlbum answers "does the autotagger have something confident
// to say about this album", for the album detail page.
//
// Three things about it are load-bearing.
//
// **It costs no MusicBrainz request.** Everything it needs is already
// on disk: `tagging_items` carries the top score and release from the
// background prefetch, and `tagging_candidates` durably holds the
// scored list. The rate limiters here are shared with every page the
// user can open, so a lookup that fires on page load must not join
// that queue — which also means this returns nothing for a folder
// nobody has scored yet, rather than scoring it now. That is the
// right trade: the prefetch will get to it, and a page that silently
// spends a minute of somebody's MusicBrainz budget to draw a banner
// is worse than a page that says nothing.
//
// **The tier is computed, not read.** `tagging_items.score` is the raw
// number and `Recommend` is what turns it into a claim — capping it
// for an ambiguous runner-up, an incomplete alignment or a folder too
// small to corroborate itself. Filtering on the raw score would
// promise confidence the scorer had explicitly withheld.
//
// **Nothing is said about an album the user has already answered
// for.** Only a `pending` group qualifies: `confirmed` covers both a
// finished apply and an explicit "leave as is", and `skipped` is the
// user saying not now. Re-offering either is nagging, and "leave as
// is" would be actively wrong to argue with.
func (s *Service) MatchForAlbum(albumID int64) (*AlbumMatchView, error) {
if albumID <= 0 {
return nil, nil //nolint:nilnil // "no album" is not an error.
}
rows, err := s.db.Queries.GetTaggingItemsForAlbum(
s.ctx, sql.NullInt64{Int64: albumID, Valid: true},
)
if err != nil {
return nil, fmt.Errorf("tagging items for album: %w", err)
}
pending := rows[:0:0]
for _, row := range rows {
if row.Status == "pending" {
pending = append(pending, row)
}
}
if len(pending) == 0 {
return nil, nil //nolint:nilnil // nothing to say is not an error.
}
// Rows arrive best-score-first, so the first pending one is the
// group worth describing. On a multi-disc album that is one disc
// of several and GroupCount says so.
best := pending[0]
cands := s.lookupCachedCandidates(best.GroupKey)
if len(cands) == 0 {
return nil, nil //nolint:nilnil // not scored yet; see the doc comment.
}
locals, err := s.scorer.LocalTracksForGroup(s.ctx, best.GroupKey)
if err != nil {
return nil, fmt.Errorf("local tracks for group: %w", err)
}
group := autotag.Group{
AlbumName: best.AlbumName,
AlbumArtist: best.AlbumArtist,
Tracks: locals,
Synthetic: best.Synthetic != 0,
}
rec := autotag.Recommend(group, cands)
if !autotag.Confident(rec) {
return nil, nil //nolint:nilnil // not confident enough to interrupt.
}
top := cands[0]
// The release the banner names must be the release Apply would
// write. Apply with an empty MBID takes the top cached candidate,
// which is what this reads — but it is passed explicitly anyway,
// so a rescore between the page rendering and the user clicking
// cannot swap the album out from under a button they have already
// read.
return &AlbumMatchView{
GroupKey: best.GroupKey,
Recommendation: string(rec),
Score: top.Score,
ReleaseMBID: top.ReleaseMBID,
Title: top.Title,
ArtistCredit: top.ArtistCredit,
TrackCount: best.TrackCount,
GroupCount: len(pending),
}, nil
}
+320
View File
@@ -0,0 +1,320 @@
package autotagservice
import (
"encoding/json"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
)
// seedAlbumGroup writes one album's files, its tagging item and the
// durable candidate blob the prefetch would have left behind.
//
// The candidate list is what a real one looks like in the two ways
// that decide the tier: a per-track alignment for every local track,
// and a runner-up far enough away not to count as ambiguity.
func seedAlbumGroup(
t *testing.T,
db *database.DB,
groupKey string,
tracks int,
status string,
score float64,
) int64 {
t.Helper()
for i := 1; i <= tracks; i++ {
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: filePathFor(groupKey, i),
Title: titleFor(i),
Artist: "Tideline",
Album: "Glass Harbour",
AlbumArtist: "Tideline",
TrackNumber: int64(i),
LengthMs: 200000,
LibraryID: 0,
GroupKey: groupKey,
})
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items
(group_key, library_id, track_count, album_name, album_artist,
disc_number, status, score, best_match_release_mbid)
VALUES (?, 0, ?, 'Glass Harbour', 'Tideline', 0, ?, ?, 'rel-1')
`, groupKey, tracks, status, score); err != nil {
t.Fatalf("insert tagging item: %v", err)
}
var albumID int64
if err := db.QueryRowWriter(
`SELECT album_id FROM audio_files WHERE group_key = ? LIMIT 1`, groupKey,
).Scan(&albumID); err != nil {
t.Fatalf("read album id: %v", err)
}
return albumID
}
func filePathFor(groupKey string, n int) string {
return "/music/" + groupKey + "/0" + string(rune('0'+n)) + ".mp3"
}
func titleFor(n int) string {
return "Track " + string(rune('0'+n))
}
// storeCandidates writes the durable blob GetCandidates would have
// cached, with `top` as the winning score.
func storeCandidates(
t *testing.T, db *database.DB, groupKey string, tracks int, top float64,
) {
t.Helper()
aligns := make([]autotag.TrackAlignment, 0, tracks)
for i := range tracks {
aligns = append(aligns, autotag.TrackAlignment{
Status: autotag.AlignmentMatched,
LocalIndex: i,
})
}
cands := []autotag.Candidate{
{
ReleaseMBID: "rel-1",
ReleaseGroupMBID: "rg-1",
Title: "Glass Harbour",
ArtistCredit: "Tideline",
TrackCount: tracks,
Alignments: aligns,
Score: top,
},
{
ReleaseMBID: "rel-2",
ReleaseGroupMBID: "rg-2",
Title: "Something Else",
ArtistCredit: "Another Band",
TrackCount: tracks,
Score: 0.40,
},
}
blob, err := json.Marshal(cands)
if err != nil {
t.Fatalf("marshal candidates: %v", err)
}
if _, err := db.ExecContext(
`INSERT INTO tagging_candidates (group_key, candidates) VALUES (?, ?)`,
groupKey, string(blob),
); err != nil {
t.Fatalf("insert candidates: %v", err)
}
}
// A confident match is what the album page exists to surface.
func TestMatchForAlbumSurfacesAConfidentMatch(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
albumID := seedAlbumGroup(t, db, "grp-1", 8, "pending", 0.95)
storeCandidates(t, db, "grp-1", 8, 0.95)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got == nil {
t.Fatal("no match returned for a strong candidate")
}
if got.Recommendation != string(autotag.RecommendationStrong) {
t.Errorf("recommendation = %q, want strong", got.Recommendation)
}
// The release named is the release Apply would write — the page
// must not offer one album and tag another.
if got.ReleaseMBID != "rel-1" || got.Title != "Glass Harbour" {
t.Errorf("named %q/%q, want rel-1/Glass Harbour", got.ReleaseMBID, got.Title)
}
if got.GroupCount != 1 {
t.Errorf("groupCount = %d, want 1", got.GroupCount)
}
}
// The tier is computed from the candidates, not read off the raw
// score — a high number the scorer would have capped must not reach
// the page as confidence it withheld.
func TestMatchForAlbumDoesNotTrustTheStoredScore(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// Two tracks: below the evidence floor, so `Recommend` caps this
// at medium however well it scores.
albumID := seedAlbumGroup(t, db, "grp-2", 2, "pending", 0.99)
storeCandidates(t, db, "grp-2", 2, 0.99)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got != nil {
t.Errorf("surfaced %+v for a two-track folder, want nothing", got)
}
}
// A weak match is not worth interrupting for.
func TestMatchForAlbumStaysQuietBelowTheTier(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
albumID := seedAlbumGroup(t, db, "grp-3", 8, "pending", 0.60)
storeCandidates(t, db, "grp-3", 8, 0.60)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got != nil {
t.Errorf("surfaced %+v for a 0.60 match, want nothing", got)
}
}
// An album the user has already answered for is not re-offered.
//
// `confirmed` covers both a finished apply and an explicit "leave as
// is", and arguing with the second would be actively wrong.
func TestMatchForAlbumRespectsAnAnswerAlreadyGiven(t *testing.T) {
t.Parallel()
for _, status := range []string{"confirmed", "skipped", "matched"} {
t.Run(status, func(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
albumID := seedAlbumGroup(t, db, "grp-"+status, 8, status, 0.95)
storeCandidates(t, db, "grp-"+status, 8, 0.95)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got != nil {
t.Errorf("surfaced %+v for a %s group, want nothing", got, status)
}
})
}
}
// A folder nobody has scored yet says nothing, rather than scoring it
// now: the MusicBrainz limiter is shared with every page the user can
// open, and this runs on page load.
func TestMatchForAlbumMakesNoNetworkCallForAnUnscoredFolder(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
// No storeCandidates: the prefetch has not reached this folder.
albumID := seedAlbumGroup(t, db, "grp-4", 8, "pending", 0.95)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got != nil {
t.Errorf("surfaced %+v with no cached candidates, want nothing", got)
}
}
// A multi-disc album is several groups, and the count is what stops
// the page offering one button that would retag one disc of two.
func TestMatchForAlbumCountsEveryGroupOfTheAlbum(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
albumID := seedAlbumGroup(t, db, "grp-d1", 8, "pending", 0.95)
storeCandidates(t, db, "grp-d1", 8, 0.95)
// Disc two: same album row, its own folder and tagging group.
for i := 1; i <= 6; i++ {
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: filePathFor("grp-d2", i),
Title: titleFor(i),
Artist: "Tideline",
Album: "Glass Harbour",
AlbumArtist: "Tideline",
TrackNumber: int64(i),
DiscNumber: 2,
LengthMs: 200000,
LibraryID: 0,
GroupKey: "grp-d2",
})
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items
(group_key, library_id, track_count, album_name, album_artist,
disc_number, status, score)
VALUES ('grp-d2', 0, 6, 'Glass Harbour', 'Tideline', 2, 'pending', 0.93)
`); err != nil {
t.Fatalf("insert disc two: %v", err)
}
storeCandidates(t, db, "grp-d2", 6, 0.93)
got, err := svc.MatchForAlbum(albumID)
if err != nil {
t.Fatalf("MatchForAlbum: %v", err)
}
if got == nil {
t.Fatal("no match returned")
}
if got.GroupCount != 2 {
t.Errorf("groupCount = %d, want 2", got.GroupCount)
}
// Best-first: the 0.95 disc is the one described.
if got.GroupKey != "grp-d1" {
t.Errorf("described %q, want the higher-scoring grp-d1", got.GroupKey)
}
}
// An album with no local files at all — a pure catalog page — is not
// a question this can answer.
func TestMatchForAlbumSaysNothingWithoutAnAlbum(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
svc := newTestService(t, db)
for _, id := range []int64{0, -1, 4242} {
got, err := svc.MatchForAlbum(id)
if err != nil {
t.Fatalf("MatchForAlbum(%d): %v", id, err)
}
if got != nil {
t.Errorf("MatchForAlbum(%d) = %+v, want nil", id, got)
}
}
}
+119 -1
View File
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults() c.General.ApplyDefaults()
} }
c.General.DefaultPage = DefaultPage(page) c.General.DefaultPage = View(page)
if err := c.General.Validate(); err != nil { if err := c.General.Validate(); err != nil {
return fmt.Errorf( return fmt.Errorf(
@@ -666,6 +666,124 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
return nil return nil
} }
// GetPopupVolume reports whether the bottom bar's volume control is a
// click-to-open popup rather than an inline slider (#42).
func (c *Config) GetPopupVolume() bool {
if c.General == nil {
return false
}
return c.General.PopupVolume
}
// SetPopupVolume saves the volume control's presentation.
//
// Nothing to validate: both values are legal at every width, and the
// frontend additionally stands the inline slider down below the phone
// breakpoint whatever this says, because that is about room rather than
// about preference.
func (c *Config) SetPopupVolume(popup bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.PopupVolume = popup
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"PopupVolume": popup,
},
)
c.logger.Info("volume control presentation updated", "popup", popup)
return nil
}
// GetViewVisibility reports which primary views the sidebar should
// show, answered for every known view rather than only the ones the
// config mentions -- so the frontend filters on a value and never has
// to hold a second copy of the defaults.
func (c *Config) GetViewVisibility() map[string]bool {
if c.General == nil {
general := &GeneralConfig{}
general.ApplyDefaults()
return general.ResolvedViewVisibility()
}
return c.General.ResolvedViewVisibility()
}
// SetViewVisible shows or hides one primary view.
//
// Two refusals, both about a state the user cannot get out of from the
// UI they would be left with: Settings is never hideable, and the
// launch page is never hideable while it is the launch page (change it
// first). Hiding a view does not make it unreachable -- `navigate`
// still resolves it, which detail views depend on -- it only takes the
// nav item away.
func (c *Config) SetViewVisible(view string, visible bool) error {
spec, known := LookupView(view)
if !known {
return fmt.Errorf("%w: %q", errUnknownView, view)
}
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
if !visible {
if !spec.Hideable {
return fmt.Errorf("%w: %q", errViewNotHideable, view)
}
if spec.ID == c.General.DefaultPage {
return fmt.Errorf("%w: %q", errViewIsLaunchPage, view)
}
}
if c.General.ViewVisibility == nil {
c.General.ViewVisibility = make(map[string]bool, len(Views))
}
c.General.ViewVisibility[view] = visible
if err := c.General.Validate(); err != nil {
return fmt.Errorf("invalid view visibility: %w", err)
}
if err := c.Save(); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"ViewVisibility": c.General.ResolvedViewVisibility(),
},
)
c.logger.Info(
"view visibility updated",
"view", view,
"visible", visible,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns. // GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column { func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil { if c.TrackList == nil {
+40
View File
@@ -188,3 +188,43 @@ func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
} }
} }
} }
// TestEmit_PopupVolumeRoundTripsAndDefaultsToInline pins both halves of
// #42's storage decision.
//
// The **default** is the load-bearing one: inline is what a fresh
// install and an existing `config.toml` with no such key must both
// produce, which is why the field names the popup rather than the
// inline slider. A flag spelled the other way round would default to
// false, hand every existing install the popup this issue exists to
// stop being the only option, and need a migration to say otherwise.
func TestEmit_PopupVolumeRoundTripsAndDefaultsToInline(t *testing.T) {
t.Parallel()
conf, rec := setupRecordedConfig(t)
if conf.GetPopupVolume() {
t.Error("a config with no PopupVolume key wants the popup, want inline")
}
if err := conf.SetPopupVolume(true); err != nil {
t.Fatalf("SetPopupVolume: %v", err)
}
if !conf.GetPopupVolume() {
t.Error("GetPopupVolume = false after setting it true")
}
data := payloadMap(t, rec, events.GeneralConfigChanged)
if data["PopupVolume"] != true {
t.Errorf("PopupVolume = %v, want true", data["PopupVolume"])
}
if err := conf.SetPopupVolume(false); err != nil {
t.Fatalf("SetPopupVolume(false): %v", err)
}
if conf.GetPopupVolume() {
t.Error("GetPopupVolume = true after setting it false")
}
}
+96 -26
View File
@@ -5,27 +5,13 @@ import (
"fmt" "fmt"
) )
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
// Valid DefaultPage values, matching the frontend's top-level route ids.
const (
DefaultPageHome DefaultPage = "home"
DefaultPageTracks DefaultPage = "tracks"
DefaultPageAlbums DefaultPage = "albums"
DefaultPageArtists DefaultPage = "artists"
DefaultPageGenres DefaultPage = "genres"
DefaultPagePlaylists DefaultPage = "playlists"
DefaultPageExplore DefaultPage = "explore"
DefaultPageDownloads DefaultPage = "downloads"
DefaultPageAutotag DefaultPage = "autotag"
DefaultPageJobs DefaultPage = "jobs"
)
// DefaultDefaultPage is the launch page for a fresh install. // DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = DefaultPageHome const DefaultDefaultPage = ViewHome
var errUnknownDefaultPage = errors.New("unknown default page") var (
errUnknownDefaultPage = errors.New("unknown default page")
errViewCannotLaunch = errors.New("view cannot be the launch page")
)
// QueueFallback identifies what plays, if anything, once the queue // QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to. // runs out with nothing left to auto-advance to.
@@ -46,18 +32,52 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't // GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem. // belong to a more specific subsystem.
type GeneralConfig struct { type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"` DefaultPage View `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"` QueueFallback QueueFallback `toml:"QueueFallback"`
// ViewVisibility says which sidebar destinations are shown, keyed by
// view id.
//
// **An absent key means that view's own default** (`Views`), and that
// is the whole reason this is a map rather than a `HiddenViews
// []string` or a struct of booleans. A list's zero value is "hide
// nothing", which cannot express Autotag being off by default without
// a migration; a struct field for a view that later stops existing is
// stored garbage somebody has to deprecate. Here a view added later
// gets its own default rather than being invisible or forcibly
// visible, an unknown key is dropped on load, and no install needs
// migrating in either direction. Same polarity rule as
// AllowMeteredCatalogDownload: the zero value is the intended answer.
ViewVisibility map[string]bool `toml:"ViewVisibility"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to // AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults // be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one, // to false, which is the whole point: the zero value is the safe one,
// so an existing config with no such key refuses by default rather // so an existing config with no such key refuses by default rather
// than needing a migration to become careful. // than needing a migration to become careful.
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"` AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
// PopupVolume draws the bottom bar's volume as a click-to-open popup
// instead of a slider that is always there (#42).
//
// The polarity is the rule this file already states twice: **the
// zero value is the intended answer**. Inline is the new default, so
// the flag has to name the *other* choice — an `InlineVolume bool`
// would default to false and give every existing install the popup
// this issue exists to stop being the only option, and would need a
// migration to say otherwise.
PopupVolume bool `toml:"PopupVolume"`
} }
// ApplyDefaults fills zero-value fields with sensible defaults. // ApplyDefaults fills zero-value fields with sensible defaults.
//
// A launch page naming a *retired* view is treated as a zero value
// rather than as an error, because the alternative is an app that will
// not start for anyone who had that page selected when it was removed.
// An unknown-but-not-retired name still fails Validate: that is a typo,
// and telling someone about it is the useful answer.
func (c *GeneralConfig) ApplyDefaults() { func (c *GeneralConfig) ApplyDefaults() {
if _, retired := RetiredViews[c.DefaultPage]; retired {
c.DefaultPage = ""
}
if c.DefaultPage == "" { if c.DefaultPage == "" {
c.DefaultPage = DefaultDefaultPage c.DefaultPage = DefaultDefaultPage
} }
@@ -71,15 +91,17 @@ func (c *GeneralConfig) ApplyDefaults() {
func (c *GeneralConfig) Validate() error { func (c *GeneralConfig) Validate() error {
c.ApplyDefaults() c.ApplyDefaults()
switch c.DefaultPage { spec, known := LookupView(string(c.DefaultPage))
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists, if !known {
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage) return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
} }
if !spec.CanLaunch {
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
}
c.normalizeViewVisibility()
switch c.QueueFallback { switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix: case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid. // Valid.
@@ -89,3 +111,51 @@ func (c *GeneralConfig) Validate() error {
return nil return nil
} }
// normalizeViewVisibility drops what the stored map may not say, and
// repairs the one invariant the shell depends on.
//
// Three things are dropped or forced, and all three are reachable only
// from a hand-edited config or from a version that knew different
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
// into Settings) says nothing to anybody; a view that is not Hideable
// cannot be false; and **the launch page is always visible**, because
// otherwise an install lands on a page with no nav item pointing at it.
//
// That last one is a *repair* here and an *error* at the setter
// (SetViewVisible), deliberately. On load there is nobody to tell and
// the honest reading of "my launch page is Autotag" is that this user
// wants Autotag, so it is un-hidden rather than the launch page being
// silently reset to something they did not choose. At the setter the
// user is right there and can act, so it refuses and says why.
func (c *GeneralConfig) normalizeViewVisibility() {
for id := range c.ViewVisibility {
spec, known := LookupView(id)
if !known || !spec.Hideable {
delete(c.ViewVisibility, id)
}
}
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
c.ViewVisibility[string(c.DefaultPage)] = true
}
}
// ResolvedViewVisibility answers for every known view, so no caller has
// to know the defaults -- the frontend included, which is why the
// binding returns this rather than the stored map.
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
resolved := make(map[string]bool, len(Views))
for _, v := range Views {
visible := v.VisibleByDefault
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
visible = stored
}
resolved[string(v.ID)] = visible
}
return resolved
}
+103
View File
@@ -0,0 +1,103 @@
package config
import "errors"
var (
errUnknownView = errors.New("unknown view")
errViewNotHideable = errors.New("view cannot be hidden")
errViewIsLaunchPage = errors.New("view is the launch page")
)
// View identifies one of the shell's primary destinations -- the
// things the sidebar lists and `index.ts` knows as `VIEW_TAGS`.
type View string
// The primary views, in no particular order: the sidebar owns the order
// it draws them in, because that is presentation.
const (
ViewHome View = "home"
ViewPlaylists View = "playlists"
ViewArtists View = "artists"
ViewGenres View = "genres"
ViewAlbums View = "albums"
ViewTracks View = "tracks"
ViewExplore View = "explore"
ViewDownloads View = "downloads"
ViewAutotag View = "autotag"
ViewSettings View = "settings"
)
// RetiredViews are destinations that used to exist and no longer do.
//
// A *visibility* entry for a removed view needs no such list: it is a
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
// is a **value**, and an unknown one fails validation -- which on the
// load path means the app refuses to start rather than a setting being
// ignored. So the one shape that cannot be retired for free is named
// here and reset to the default instead.
//
// `jobs` was folded into Settings by #27: library scans under
// Libraries, index work under Search Index, downloads under the
// download clients, and the autotag apply into the Autotag view.
var RetiredViews = map[View]struct{}{
"jobs": {},
}
// ViewSpec is what the backend knows about a destination. The label and
// the icon are deliberately absent: those are presentation, they live
// beside the rest of the app's icon vocabulary in
// `frontend/src/utils/icon-language.ts`, and a Go copy of them would be
// a second thing to keep in step for nothing.
type ViewSpec struct {
// ID is the view name the frontend navigates by.
ID View
// VisibleByDefault is what an install gets when the config says
// nothing about this view -- which is every install until somebody
// changes it, and every view added after this one shipped.
VisibleByDefault bool
// Hideable is false for Settings alone. It is a property of the
// view rather than a check in the setter because `config.toml` is
// hand-editable, and an app that can be locked out of its own
// Settings by a typo is a support problem nobody can debug
// remotely.
Hideable bool
// CanLaunch reports whether the view may be the launch page.
// Settings is the only one that may not, which is the shape the
// DefaultPage enum already had.
CanLaunch bool
}
// Views is the one list of primary destinations, in the order Settings
// offers them.
//
// It is the single source for three things that used to be written down
// separately: which views exist, which of them may be the launch page
// (`DefaultPage`'s validation reads it), and what an unconfigured
// install shows.
//
// Autotag is the one view hidden by default: it rewrites tags on disk,
// which is not what most libraries want on day one, and #25 asks for it
// to be turned on deliberately.
var Views = []ViewSpec{
{ID: ViewHome, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewPlaylists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewArtists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewGenres, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAlbums, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewTracks, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
}
// LookupView returns the spec for a view id.
func LookupView(id string) (ViewSpec, bool) {
for _, v := range Views {
if string(v.ID) == id {
return v, true
}
}
return ViewSpec{}, false
}
+307
View File
@@ -0,0 +1,307 @@
package config
import (
"errors"
"log/slog"
"path/filepath"
"testing"
)
// newViewTestConfig builds a Config backed by a temp file, which is all
// SetViewVisible needs: it saves and emits, and the emit is a no-op
// without a running app.
func newViewTestConfig(t *testing.T) *Config {
t.Helper()
c := &Config{
logger: slog.Default(),
filePath: filepath.Join(t.TempDir(), "config.toml"),
}
// Load a file that is not there: that is what marks the config
// loaded, without which Save refuses on the *second* write.
if err := c.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
return c
}
// A view the config says nothing about takes its own default, which is
// what makes this need no migration in either direction: an existing
// install gets Autotag hidden without a key, and a view added later
// gets its own answer rather than the list's.
func TestViewVisibilityDefaults(t *testing.T) {
t.Parallel()
general := &GeneralConfig{}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if len(resolved) != len(Views) {
t.Fatalf("resolved %d views, want %d", len(resolved), len(Views))
}
if resolved[string(ViewAutotag)] {
t.Error("autotag should be hidden by default")
}
for _, v := range Views {
if v.ID == ViewAutotag {
continue
}
if !resolved[string(v.ID)] {
t.Errorf("%s should be visible by default", v.ID)
}
}
}
// A stored answer wins over the default, in both directions -- turning
// Autotag on is the whole user-facing point.
func TestViewVisibilityStoredWins(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
string(ViewAutotag): true,
string(ViewExplore): false,
},
}
general.ApplyDefaults()
resolved := general.ResolvedViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore was switched off and should be hidden")
}
}
// A key for a view that no longer exists is discarded rather than
// migrated. This is the property the #25-before-#27 ordering rests on:
// when Jobs folds into Settings, `jobs = true` in somebody's config is
// a key nothing asks about, not a cleanup task.
func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
ViewVisibility: map[string]bool{
"a-view-that-was-removed": true,
string(ViewSettings): false,
string(ViewAutotag): true,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok {
t.Error("an unknown view id should be dropped on load")
}
if _, ok := general.ViewVisibility[string(ViewSettings)]; ok {
t.Error("settings is not hideable and should not be stored")
}
if !general.ResolvedViewVisibility()[string(ViewSettings)] {
t.Error("settings must resolve visible whatever the file said")
}
}
// On load there is nobody to tell, so a launch page hidden by a
// hand-edited file is un-hidden rather than the launch page being
// reset to something the user did not choose.
func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{
DefaultPage: ViewAutotag,
ViewVisibility: map[string]bool{
string(ViewAutotag): false,
},
}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if !general.ResolvedViewVisibility()[string(ViewAutotag)] {
t.Error("the launch page must be visible")
}
}
// A launch page naming a view that no longer exists resets to the
// default instead of failing validation, which on the load path would
// mean the app refusing to start for whoever had it selected.
//
// This is the one shape #25's storage decision does *not* make free: a
// visibility entry is a key and an unknown key is dropped, but a launch
// page is a value.
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "jobs"}
if err := general.Validate(); err != nil {
t.Fatalf("Validate() error: %v", err)
}
if general.DefaultPage != DefaultDefaultPage {
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
}
}
// A name that is merely wrong is still an error: that is a typo, and
// saying so is more useful than ignoring it.
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: "nonsense"}
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
}
}
// A retired view is not a view, so nothing offers it and nothing
// resolves it -- the visibility map included.
func TestARetiredViewIsGone(t *testing.T) {
t.Parallel()
for id := range RetiredViews {
if _, ok := LookupView(string(id)); ok {
t.Errorf("%s is retired but still in Views", id)
}
general := &GeneralConfig{}
general.ApplyDefaults()
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
t.Errorf("%s is retired but still resolves a visibility", id)
}
}
}
// Settings may not be the launch page, which is the shape the old
// DefaultPage enum had and is now read off the same table.
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
t.Parallel()
general := &GeneralConfig{DefaultPage: ViewSettings}
err := general.Validate()
if !errors.Is(err, errViewCannotLaunch) {
t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err)
}
}
// At the setter the user is present and can act, so the two states
// they could not get out of are refused rather than repaired.
func TestSetViewVisibleRefusals(t *testing.T) {
t.Parallel()
tests := []struct {
name string
view string
visible bool
want error
}{
{"settings is never hideable", string(ViewSettings), false, errViewNotHideable},
{"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage},
{"an unknown view is not a setting", "nonsense", false, errUnknownView},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
err := c.SetViewVisible(tt.view, tt.visible)
if !errors.Is(err, tt.want) {
t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want)
}
})
}
}
// Showing a view is never refused, including Settings and the launch
// page -- there is no state to be stuck in.
func TestSetViewVisibleShowsAnything(t *testing.T) {
t.Parallel()
c := newViewTestConfig(t)
for _, v := range Views {
if err := c.SetViewVisible(string(v.ID), true); err != nil {
t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err)
}
}
if !c.GetViewVisibility()[string(ViewAutotag)] {
t.Error("autotag was switched on and should be visible")
}
}
// The stored map survives a save/load round trip, which is what a
// map-valued TOML key is worth checking for.
func TestViewVisibilityRoundTrips(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.toml")
original := &Config{logger: slog.Default(), filePath: path}
if err := original.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
if err := original.SetViewVisible(string(ViewAutotag), true); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
t.Fatalf("SetViewVisible() error: %v", err)
}
loaded := &Config{logger: slog.Default(), filePath: path}
if err := loaded.Load(); err != nil {
t.Fatalf("Load() error: %v", err)
}
resolved := loaded.GetViewVisibility()
if !resolved[string(ViewAutotag)] {
t.Error("autotag should have loaded as visible")
}
if resolved[string(ViewExplore)] {
t.Error("explore should have loaded as hidden")
}
}
// Every view the shell can launch into is a view the sidebar can show,
// or an install could land on a page with no nav item and no setting
// pointing at it.
func TestEveryLaunchableViewIsAView(t *testing.T) {
t.Parallel()
for _, v := range Views {
if !v.CanLaunch {
continue
}
if !v.Hideable {
continue
}
if _, ok := LookupView(string(v.ID)); !ok {
t.Errorf("%s is launchable but not a known view", v.ID)
}
}
}
+25 -7
View File
@@ -13,13 +13,31 @@ const (
// enforces this at runtime; it is also the floor below which a // enforces this at runtime; it is also the floor below which a
// reported size is treated as bogus and not persisted. // reported size is treated as bogus and not persisted.
// //
// 800x600 is where the shell was measured to still work, rather // **Both reasons this comment used to give have expired**, and the
// than a round number: below ~780 the header's subtitle wraps and // value is right for a third one. It said the floor was 800x600
// pushes the title out of the 4em top bar, and below ~600 tall the // because "below ~780 the header's subtitle wraps and pushes the
// eleven sidebar items no longer fit at once. The previous // title out of the 4em top bar" and "below ~600 tall the eleven
// 512x384 was aspirational — at 700x480 the sidebar overflowed // sidebar items no longer fit at once". Neither mechanism can
// behind the player bar with no scroll and Settings and Jobs could // happen now: the subtitle is display:none from 899px down
// not be reached at all. // (index.css), and the sidebar host is overflow-y:auto — measured
// at 600x460, its scrollHeight is 434 against a 332px client and
// Settings is reachable after scrolling. A floor defended by two
// mechanisms that no longer exist is a number nobody can argue
// with, which is worse than either answer.
//
// It stays 800x600 because that is where the *desktop* chrome
// stops being comfortable — the Compact band of plan 018's size
// matrix (#24) — and not because the app breaks below it. It does
// not: under 600px wide the phone layout takes over (bottom-nav,
// no sidebar) and the shell fits 320px exactly, which is what
// makes this a comfort floor rather than a correctness one, and
// why a very small window reflows instead of becoming a
// mini-player (#12 is a second always-on-top window, not a mode of
// this one).
//
// The previous 512x384 was aspirational — at 700x480 the sidebar
// overflowed behind the player bar with no scroll and Settings and
// Jobs could not be reached at all.
MinWidth = 800 MinWidth = 800
// MinHeight is the smallest allowed window height in pixels. // MinHeight is the smallest allowed window height in pixels.
MinHeight = 600 MinHeight = 600
+46
View File
@@ -135,3 +135,49 @@ SELECT
) AS INTEGER) AS known ) AS INTEGER) AS known
FROM audio_files a FROM audio_files a
WHERE a.album_id = sqlc.arg(album_id); WHERE a.album_id = sqlc.arg(album_id);
-- name: GetAlbumsCompleteness :many
-- The same question as GetAlbumCompleteness, asked of a screenful of
-- albums at once.
--
-- A card grid cannot afford one query per card, and the answer it wants
-- is the one thing a badge cannot guess: an album held 9 tracks of 12
-- must show the count, never a bare tick. So this is one query for the
-- whole grid, asked only of the cards that have a local album id.
--
-- It is two grouping levels rather than the single-album form's
-- correlated subqueries, because a correlated subquery in the FROM
-- clause is not something SQLite will reliably do -- and because the
-- slice may only be spelled once, or sqlc expands it twice with
-- independently numbered placeholders.
--
-- The per-disc level is where the meaning is, and it is the same
-- meaning as the single-album query. `owned` counts DISTINCT track
-- numbers within a disc (this app detects duplicates, and counting two
-- files of track 3 twice would report a short album as complete), with
-- a file that declares no track number falling back to its own id
-- because three untagged files are three tracks and not one.
-- `expected` takes each disc's declared total and sums over discs,
-- since a total is declared per disc and a release total written on
-- every file of a two-disc album would double its expectation. A disc
-- whose files declared nothing contributes a NULL that SUM ignores,
-- and `known` is what says the album is therefore unanswerable.
WITH per_disc AS (
SELECT
album_id AS album_id,
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
AS owned_on_disc,
MAX(total_tracks) AS disc_total,
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
AS discs_without_a_total
FROM audio_files
WHERE album_id IN (sqlc.slice('album_ids'))
GROUP BY album_id, COALESCE(disc_number, 1)
)
SELECT
CAST(album_id AS INTEGER) AS album_id,
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
FROM per_disc
GROUP BY album_id;
@@ -342,3 +342,35 @@ WHERE ti.status = 'pending'
) )
ORDER BY ti.group_key ORDER BY ti.group_key
LIMIT 1; LIMIT 1;
-- name: GetTaggingItemsForAlbum :many
-- Every tagging group holding a file of this album.
--
-- The join is `audio_files.group_key`, not a key derived from the
-- album's folder path: a group carved out of a mixed-bag folder by
-- SplitMixedFolder is keyed on its tags rather than on a directory,
-- so a path-derived key finds nothing for exactly the messiest
-- libraries this is meant to help.
--
-- Usually one row. A multi-disc album is one group per disc, which
-- the caller has to know about rather than average over -- applying
-- to "the album" would silently retag one disc of three.
SELECT
ti.group_key,
ti.status,
ti.score,
ti.best_match_release_mbid,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.synthetic
FROM tagging_items ti
WHERE ti.group_key IN (
SELECT DISTINCT af.group_key
FROM audio_files af
WHERE af.album_id = sqlc.arg(album_id) AND af.group_key != ''
)
AND ti.cleared_at IS NULL
-- Best first, with an unscored group last rather than first: NULL
-- sorts low in SQLite and DESC would put it at the top.
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key;
@@ -8,6 +8,7 @@ package sqlcgen
import ( import (
"context" "context"
"database/sql" "database/sql"
"strings"
) )
const deleteAlbum = `-- name: DeleteAlbum :exec const deleteAlbum = `-- name: DeleteAlbum :exec
@@ -234,6 +235,98 @@ func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArti
return items, nil return items, nil
} }
const getAlbumsCompleteness = `-- name: GetAlbumsCompleteness :many
WITH per_disc AS (
SELECT
album_id AS album_id,
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
AS owned_on_disc,
MAX(total_tracks) AS disc_total,
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
AS discs_without_a_total
FROM audio_files
WHERE album_id IN (/*SLICE:album_ids*/?)
GROUP BY album_id, COALESCE(disc_number, 1)
)
SELECT
CAST(album_id AS INTEGER) AS album_id,
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
FROM per_disc
GROUP BY album_id
`
type GetAlbumsCompletenessRow struct {
AlbumID int64
Owned int64
Expected int64
Known int64
}
// The same question as GetAlbumCompleteness, asked of a screenful of
// albums at once.
//
// A card grid cannot afford one query per card, and the answer it wants
// is the one thing a badge cannot guess: an album held 9 tracks of 12
// must show the count, never a bare tick. So this is one query for the
// whole grid, asked only of the cards that have a local album id.
//
// It is two grouping levels rather than the single-album form's
// correlated subqueries, because a correlated subquery in the FROM
// clause is not something SQLite will reliably do -- and because the
// slice may only be spelled once, or sqlc expands it twice with
// independently numbered placeholders.
//
// The per-disc level is where the meaning is, and it is the same
// meaning as the single-album query. `owned` counts DISTINCT track
// numbers within a disc (this app detects duplicates, and counting two
// files of track 3 twice would report a short album as complete), with
// a file that declares no track number falling back to its own id
// because three untagged files are three tracks and not one.
// `expected` takes each disc's declared total and sums over discs,
// since a total is declared per disc and a release total written on
// every file of a two-disc album would double its expectation. A disc
// whose files declared nothing contributes a NULL that SUM ignores,
// and `known` is what says the album is therefore unanswerable.
func (q *Queries) GetAlbumsCompleteness(ctx context.Context, albumIds []sql.NullInt64) ([]GetAlbumsCompletenessRow, error) {
query := getAlbumsCompleteness
var queryParams []interface{}
if len(albumIds) > 0 {
for _, v := range albumIds {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:album_ids*/?", strings.Repeat(",?", len(albumIds))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:album_ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumsCompletenessRow
for rows.Next() {
var i GetAlbumsCompletenessRow
if err := rows.Scan(
&i.AlbumID,
&i.Owned,
&i.Expected,
&i.Known,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != '' WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
@@ -231,6 +231,82 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
return i, err return i, err
} }
const getTaggingItemsForAlbum = `-- name: GetTaggingItemsForAlbum :many
SELECT
ti.group_key,
ti.status,
ti.score,
ti.best_match_release_mbid,
ti.track_count,
ti.album_name,
ti.album_artist,
ti.synthetic
FROM tagging_items ti
WHERE ti.group_key IN (
SELECT DISTINCT af.group_key
FROM audio_files af
WHERE af.album_id = ?1 AND af.group_key != ''
)
AND ti.cleared_at IS NULL
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key
`
type GetTaggingItemsForAlbumRow struct {
GroupKey string
Status string
Score sql.NullFloat64
BestMatchReleaseMbid sql.NullString
TrackCount int64
AlbumName string
AlbumArtist string
Synthetic int64
}
// Every tagging group holding a file of this album.
//
// The join is `audio_files.group_key`, not a key derived from the
// album's folder path: a group carved out of a mixed-bag folder by
// SplitMixedFolder is keyed on its tags rather than on a directory,
// so a path-derived key finds nothing for exactly the messiest
// libraries this is meant to help.
//
// Usually one row. A multi-disc album is one group per disc, which
// the caller has to know about rather than average over -- applying
// to "the album" would silently retag one disc of three.
// Best first, with an unscored group last rather than first: NULL
// sorts low in SQLite and DESC would put it at the top.
func (q *Queries) GetTaggingItemsForAlbum(ctx context.Context, albumID sql.NullInt64) ([]GetTaggingItemsForAlbumRow, error) {
rows, err := q.db.QueryContext(ctx, getTaggingItemsForAlbum, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTaggingItemsForAlbumRow
for rows.Next() {
var i GetTaggingItemsForAlbumRow
if err := rows.Scan(
&i.GroupKey,
&i.Status,
&i.Score,
&i.BestMatchReleaseMbid,
&i.TrackCount,
&i.AlbumName,
&i.AlbumArtist,
&i.Synthetic,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
SELECT SELECT
af.id, af.id,
+9
View File
@@ -2212,6 +2212,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type, ArtistType: a.Type,
Country: a.Country, Country: a.Country,
InLibrary: a.InLibrary, InLibrary: a.InLibrary,
LocalID: a.LocalID,
}, },
category: "artist", category: "artist",
qualityScore: quality, qualityScore: quality,
@@ -2243,6 +2244,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type, ArtistType: a.Type,
Country: a.Country, Country: a.Country,
InLibrary: a.InLibrary, InLibrary: a.InLibrary,
LocalID: a.LocalID,
}, },
category: "artist", category: "artist",
qualityScore: quality, qualityScore: quality,
@@ -2275,6 +2277,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType, PrimaryType: rg.PrimaryType,
Year: year, Year: year,
InLibrary: rg.InLibrary, InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
}, },
category: "release_group", category: "release_group",
qualityScore: quality, qualityScore: quality,
@@ -2320,6 +2323,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType, PrimaryType: rg.PrimaryType,
Year: year, Year: year,
InLibrary: rg.InLibrary, InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
}, },
category: "release_group", category: "release_group",
qualityScore: quality, qualityScore: quality,
@@ -2347,6 +2351,7 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID, CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName, ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary, InLibrary: r.InLibrary,
LocalID: r.LocalID,
}, },
category: "recording", category: "recording",
qualityScore: quality, qualityScore: quality,
@@ -2403,6 +2408,7 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID, CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName, ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary, InLibrary: r.InLibrary,
LocalID: r.LocalID,
}, },
category: "recording", category: "recording",
qualityScore: quality, qualityScore: quality,
@@ -2425,6 +2431,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: m.ArtistType, ArtistType: m.ArtistType,
Country: m.Country, Country: m.Country,
InLibrary: m.InLibrary || m.LocalArtistID > 0, InLibrary: m.InLibrary || m.LocalArtistID > 0,
LocalID: m.LocalArtistID,
}, },
category: "artist", category: "artist",
qualityScore: quality, qualityScore: quality,
@@ -2445,6 +2452,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: m.PrimaryType, PrimaryType: m.PrimaryType,
Year: year, Year: year,
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0, InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
LocalID: m.LocalReleaseGroupID,
}, },
category: "release_group", category: "release_group",
qualityScore: quality, qualityScore: quality,
@@ -2459,6 +2467,7 @@ func (e *Service) gatherTopCandidates(
ArtistMBID: m.ArtistMBID, ArtistMBID: m.ArtistMBID,
Length: m.Duration, Length: m.Duration,
InLibrary: m.InLibrary || m.LocalRecordingID > 0, InLibrary: m.InLibrary || m.LocalRecordingID > 0,
LocalID: m.LocalRecordingID,
}, },
category: "recording", category: "recording",
qualityScore: quality, qualityScore: quality,
+94
View File
@@ -82,6 +82,100 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) {
} }
} }
// TestPruneClearsInLibraryWithNoLocalID covers the fixed point: a row
// carrying in_library with a NULL local_*_id. The upsert's conflict
// clause is `in_library = MAX(in_library, excluded.in_library)`, so it
// can only ever raise the flag, and this pass used to be gated on the id
// being present — which meant nothing in the app could clear such a row,
// ever. It is asserted for all three entity types because the gate was
// written once and used three times, so a fix applied to one is a fix
// that looks complete.
//
// The rows are seeded with raw SQL rather than through seedIndexResult
// deliberately: upsertBatch writes a zero LocalArtistID as literal 0,
// not NULL, and 0 satisfies `IS NOT NULL` — so the old gate already
// caught that shape and a fixture built through the upsert cannot
// reproduce this at all. NULL is what the artifact importer and any
// older writer leave behind, the column being nullable with no default.
func TestPruneClearsInLibraryWithNoLocalID(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, slog.Default())
// A genuinely owned artist, to prove the wider gate does not simply
// clear everything it now looks at.
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: "/music/owned.mp3",
Artist: "Owned",
})
artist, err := db.Queries.GetArtistByName(t.Context(), "Owned")
if err != nil {
t.Fatalf("read seeded artist: %v", err)
}
seedIndexResult(t, db, SearchIndexResult{
EntityType: EntityArtist,
MBID: testMBID("owned"),
Title: "Owned",
ArtistName: "Owned",
ArtistMBID: testMBID("owned"),
InLibrary: true,
LocalArtistID: artist.ID,
})
orphans := []struct {
name string
entityType string
mbid string
}{
{"artist", EntityArtist, "orphan-artist"},
{"release group", EntityReleaseGroup, "orphan-release-group"},
{"recording", EntityRecording, "orphan-recording"},
}
for _, o := range orphans {
if _, err := db.ExecContext(
`INSERT INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid,
in_library,
local_artist_id, local_release_group_id, local_recording_id)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
dbEntityType(o.entityType), dbMBID(testMBID(o.mbid)), o.name, o.name,
dbMBID(testMBID(o.mbid)),
nil, nil, nil,
); err != nil {
t.Fatalf("seed %s orphan: %v", o.name, err)
}
}
si.pruneStaleLocalCrossReferences()
inLibrary := func(t *testing.T, mbid string) int {
t.Helper()
var flag int
if err := db.QueryRowWriter(
"SELECT in_library FROM explore_index WHERE mbid = ?", dbMBID(mbid),
).Scan(&flag); err != nil {
t.Fatalf("read in_library for %q: %v", mbid, err)
}
return flag
}
for _, o := range orphans {
if got := inLibrary(t, testMBID(o.mbid)); got != 0 {
t.Errorf("%s with a NULL local id: in_library = %d, want 0", o.name, got)
}
}
if got := inLibrary(t, testMBID("owned")); got != 1 {
t.Errorf("owned artist: in_library = %d, want 1 (it still has a file)", got)
}
}
// TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the // TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the
// backfill queue prioritizes artists by how many tracks the user actually // backfill queue prioritizes artists by how many tracks the user actually
// owns, not by how many duplicate-mbid artist rows happen to exist (the // owns, not by how many duplicate-mbid artist rows happen to exist (the
+15 -1
View File
@@ -2562,6 +2562,19 @@ func (si *SearchIndex) PopulateLocalCrossReferences() {
// The row itself is left in place (it may still be part of the shipped // The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping // catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared. // is cleared.
//
// It is gated on the flag *or* the id, not on the id alone. Gated on
// the id, `in_library = 1 AND local_*_id IS NULL` is a fixed point: the
// upsert can only ever raise the flag and this pass skipped such a row
// by construction, so nothing in the app could clear it — a row claiming
// to be owned, permanently, with no local row to check the claim
// against. Nothing in the tree writes that shape today
// (collectLibraryEntities sets both together), which is exactly why it
// is worth closing now: the exposure is a database written by an older
// version, and the next writer that sets the flag without an id, which
// nothing structurally prevents. A NULL id fails the existence test on
// its own, so the wider gate needs no second clause to say what "not
// owned" means.
func (si *SearchIndex) pruneStaleLocalCrossReferences() { func (si *SearchIndex) pruneStaleLocalCrossReferences() {
type prune struct { type prune struct {
entityType string entityType string
@@ -2594,7 +2607,8 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() {
result, err := si.db.ExecContext( result, err := si.db.ExecContext(
`UPDATE explore_index `UPDATE explore_index
SET in_library = 0, `+p.column+` = NULL SET in_library = 0, `+p.column+` = NULL
WHERE entity_type = ? AND `+p.column+` IS NOT NULL WHERE entity_type = ?
AND (`+p.column+` IS NOT NULL OR in_library = 1)
AND NOT EXISTS (`+p.exists+`)`, AND NOT EXISTS (`+p.exists+`)`,
dbEntityType(p.entityType), dbEntityType(p.entityType),
) )
+10 -1
View File
@@ -41,7 +41,16 @@ type TopResult struct {
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"` ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
ReleaseName string `json:"releaseName,omitempty"` ReleaseName string `json:"releaseName,omitempty"`
// Library status — populated from index cross-reference columns. // Library status — populated from index cross-reference columns.
InLibrary bool `json:"inLibrary"` //
// LocalID is the one the cards read. It is the local row behind
// this entity — an album, a file, an artist — and it is set and
// cleared by a test against `audio_files`, so it means "there is
// something of mine here". InLibrary is written by the same pass
// but is a one-way ratchet the prune can only clear alongside a
// local id, so it is the weaker of the two and stays for scoring
// (`fwInLibrary`), which is where an approximate answer is fine.
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
} }
// MBArtist is a Wails-friendly projection of a MusicBrainz artist. // MBArtist is a Wails-friendly projection of a MusicBrainz artist.
+109
View File
@@ -232,3 +232,112 @@ func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Errorf("empty album reported %+v, want zero and unknown", got) t.Errorf("empty album reported %+v, want zero and unknown", got)
} }
} }
// The batch and the single-album query are two spellings of one
// question, and the thing worth pinning is that they never disagree.
//
// They are genuinely different SQL — the single-album form is
// correlated subqueries over one album, the batch is two grouping
// levels over a slice — so the risk is not a typo but a drift in
// meaning: a disc's total counted once per file, a duplicate counted
// twice, a disc with no total silently covered by one that had one.
// Every shape the table above cares about is staged here at once,
// because a batch that is only ever asked about one album is not being
// asked the question that can go wrong.
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
shapes := map[int][]track{
1: disc(1, 100, 12, 12),
2: disc(1, 200, 9, 12),
3: disc(1, 300, 13, 12),
4: {{recordingID: 400, disc: 1, number: 1}},
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
6: append(
disc(1, 700, 10, 10),
track{recordingID: 750, disc: 2, number: 1},
),
7: append(
disc(1, 800, 5, 6),
track{recordingID: 899, disc: 1, number: 3, total: 6},
),
}
ids := make([]int64, 0, len(shapes))
for albumID, tracks := range shapes {
stageAlbum(t, lib, albumID, tracks)
ids = append(ids, albumIDFor(t, lib, albumID))
}
batch, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if len(batch) != len(ids) {
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
}
for _, id := range ids {
one, err := lib.GetAlbumCompleteness(id)
if err != nil {
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
}
if got := batch[id]; got != one {
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
}
}
}
// An album with no files is absent from the batch, not zeroed.
//
// "I have none of this" and "I have no idea" are the third state Known
// exists to keep apart, and a caller reading a missing key gets nothing
// rather than a confident zero it would have to know to distrust.
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
held := albumIDFor(t, lib, 1)
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if _, ok := got[4242]; ok {
t.Errorf("an album with no files answered %+v, want absent", got[4242])
}
if !got[held].Complete {
t.Errorf("held album reported %+v, want complete", got[held])
}
}
// A caller with nothing to ask about must not issue a query at all —
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
// a perfectly valid query returning nothing, so this is about the round
// trip rather than the answer.
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
got, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
}
if len(got) != 0 {
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
}
}
}
+59
View File
@@ -244,6 +244,65 @@ func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error)
}, nil }, nil
} }
// GetAlbumsCompleteness answers the same question for a screenful of
// albums in one query, keyed by album id.
//
// A card grid asks this about every card that has a local album behind
// it, and one query per card is how a grid of fifty albums becomes
// fifty round trips. The answer matters there for the reason it
// matters on the album page: an album held 9 tracks of 12 has to show
// the count, and a bare tick saying "in your library" is the complaint
// this whole rule came from.
//
// An album with no row in the result is one with no files, and it is
// absent rather than zeroed — "I have none of this" and "I have no
// idea" are the same third state `Known` exists to keep apart, and a
// caller reading a missing key gets nothing rather than a confident 0.
func (l *Library) GetAlbumsCompleteness(
albumIDs []int64,
) (map[int64]AlbumCompleteness, error) {
out := make(map[int64]AlbumCompleteness, len(albumIDs))
if len(albumIDs) == 0 {
return out, nil
}
keys := make([]sql.NullInt64, 0, len(albumIDs))
for _, id := range albumIDs {
if id <= 0 {
continue
}
keys = append(keys, sql.NullInt64{Int64: id, Valid: true})
}
if len(keys) == 0 {
return out, nil
}
rows, err := l.db.ReadQueries.GetAlbumsCompleteness(l.ctx, keys)
if err != nil {
l.logger.Error("could not get album completeness in batch",
"albums", len(keys), "error", err)
return nil, fmt.Errorf("could not get album completeness: %w", err)
}
for _, row := range rows {
known := row.Known != 0 && row.Expected > 0
out[row.AlbumID] = AlbumCompleteness{
Owned: int(row.Owned),
Expected: int(row.Expected),
Known: known,
Complete: known && row.Owned >= row.Expected,
}
}
return out, nil
}
// GetAlbumTracks returns one album's tracks in disc/track order. // GetAlbumTracks returns one album's tracks in disc/track order.
func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) { func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) {
rows, err := l.db.ReadQueries.GetTracksByAlbum( rows, err := l.db.ReadQueries.GetTracksByAlbum(
+188
View File
@@ -0,0 +1,188 @@
package player
import (
"errors"
"testing"
"time"
"github.com/gopxl/beep/v2"
)
// errTestDecode stands in for a decoder blowing up mid-track.
var errTestDecode = errors.New("decode blew up")
// stalledStreamer never produces a sample and never reports
// end-of-stream: (0, true), forever. A damaged file that decodes to
// nothing looks like this, and so does any source whose producer has
// quietly stopped.
type stalledStreamer struct{}
func (stalledStreamer) Stream(_ [][2]float64) (int, bool) { return 0, true }
func (stalledStreamer) Err() error { return nil }
// failingStreamer produces n good samples and then fails, which is
// what a decode error mid-track looks like: the same (0, false) a
// finished track returns, distinguishable only by Err.
type failingStreamer struct {
remaining int
err error
}
func (f *failingStreamer) Stream(samples [][2]float64) (int, bool) {
if f.remaining <= 0 {
return 0, false
}
n := min(len(samples), f.remaining)
for i := range n {
samples[i] = [2]float64{1, 1}
}
f.remaining -= n
return n, true
}
func (f *failingStreamer) Err() error { return f.err }
// drainUntilEnd calls Stream until it reports end-of-stream, or gives
// up. It returns whether the stream ended.
//
// The give-up bound is wall clock rather than a call count: the stall
// budget is a duration, so a tight loop has to actually wait it out.
func drainUntilEnd(bs *BufferedStreamer, within time.Duration) bool {
buf := make([][2]float64, 512)
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if _, ok := bs.Stream(buf); !ok {
return true
}
time.Sleep(time.Millisecond)
}
return false
}
// A source that stops producing without ever ending is the fault this
// whole file exists for: Stream used to answer with silence and ok
// forever, so the chain never ended, the player stayed in Playing
// with the button showing pause, and the decoder's position never
// moved -- a frozen seek bar over a track that was not playing.
func TestAStalledSourceEndsTheStream(t *testing.T) {
bs := NewBufferedStreamer(stalledStreamer{}, 2048)
defer bs.Close()
if !drainUntilEnd(bs, maxStarvedDuration+2*time.Second) {
t.Fatal(
"a stalled source never ended the stream: the player " +
"would sit in Playing with a frozen position",
)
}
if !errors.Is(bs.Err(), errSourceStalled) {
t.Fatalf(
"expected the stall to be reported, got %v", bs.Err(),
)
}
}
// Close is the other exit that used to leave `done` false, with the
// same consequence: the ring drains and every call after it is
// silence that claims to be audio.
func TestClosingEndsTheStream(t *testing.T) {
bs := NewBufferedStreamer(finiteStreamer(1<<20), 2048)
// Let the read-ahead fill something, so this exercises the drain
// after Close rather than a buffer that was empty anyway.
time.Sleep(20 * time.Millisecond)
bs.Close()
if !drainUntilEnd(bs, 2*time.Second) {
t.Fatal("a closed streamer never reported end-of-stream")
}
}
// A source that fails is not a source that finished, and only Err
// tells them apart. Before this, the player reported a mid-track
// decode failure to the queue as a natural end, so the queue
// auto-advanced in silence and counted the broken track as played.
func TestAFailedSourceReportsItsError(t *testing.T) {
src := &failingStreamer{remaining: 4096, err: errTestDecode}
bs := NewBufferedStreamer(src, 2048)
defer bs.Close()
if !drainUntilEnd(bs, 2*time.Second) {
t.Fatal("a failing source never reported end-of-stream")
}
if !errors.Is(bs.Err(), errTestDecode) {
t.Fatalf(
"expected the source's error to survive, got %v",
bs.Err(),
)
}
}
// The ordinary case has to keep working: a source that ends cleanly
// ends with no error, or every finished track would be reported as a
// failure and skipped.
func TestADrainedSourceReportsNoError(t *testing.T) {
bs := NewBufferedStreamer(finiteStreamer(4096), 2048)
defer bs.Close()
if !drainUntilEnd(bs, 2*time.Second) {
t.Fatal("a finite source never reported end-of-stream")
}
if bs.Err() != nil {
t.Fatalf(
"a track that finished normally reported %v", bs.Err(),
)
}
}
// A slow source is exactly what the read-ahead exists to absorb, so
// underruns must not be charged cumulatively -- otherwise a file on a
// slow disk ends itself partway through.
func TestUnderrunsDoNotAccumulateAcrossASlowSource(t *testing.T) {
const total = 8192
src := &slowStreamer{
inner: finiteStreamer(total),
delay: 2 * time.Millisecond,
}
bs := NewBufferedStreamer(src, 1024)
defer bs.Close()
buf := make([][2]float64, 256)
got := 0
for {
n, ok := bs.Stream(buf)
if !ok {
break
}
for i := range n {
if buf[i][0] != 0 {
got++
}
}
}
if got != total {
t.Fatalf(
"a slow but healthy source was cut short: got %d of %d "+
"samples",
got, total,
)
}
}
// beep.Streamer is what the player wraps; keep the type honest.
var _ beep.Streamer = (*BufferedStreamer)(nil)
+113 -9
View File
@@ -1,6 +1,7 @@
package player package player
import ( import (
"errors"
"sync" "sync"
"time" "time"
@@ -34,8 +35,45 @@ type BufferedStreamer struct {
done bool done bool
err error err error
closed chan struct{} closed chan struct{}
// starved counts consecutive Stream calls served with silence
// because the ring was empty, and starvedSince is when that run
// began. An underrun is legitimate for a moment -- that is what
// the read-ahead exists to absorb -- but it is not legitimate
// forever, and "forever" is indistinguishable from healthy
// playback everywhere above this type: the chain never ends, so
// the player stays in Playing with the button showing pause, and
// the decoder's position never moves, so the 1 Hz report pins the
// seek bar and suppresses its interpolation.
starved int
starvedSince time.Time
} }
// The silence fill is bounded by both a duration and a run of calls,
// and it needs both.
//
// Duration alone is the real measure -- the speaker paces itself, so
// wall clock is what says whether the source has actually stopped --
// but a caller draining in a tight loop (a test, a decode-to-buffer)
// makes hundreds of calls in microseconds and would trip nothing.
// A call count alone is the opposite failure: the same tight loop
// spends the whole budget before the read-ahead goroutine has been
// scheduled once, and ends a perfectly good stream at sample zero.
//
// The duration is longer than the 2 s read-ahead it is there to
// outlast, and the count is short enough that the speaker (~200 ms a
// call) reaches it well inside that.
const (
maxStarvedDuration = 3 * time.Second
minStarvedCalls = 8
)
// errSourceStalled is returned by Err when the source stopped
// producing samples without ever reporting end-of-stream.
var errSourceStalled = errors.New(
"audio source stopped producing samples",
)
// NewBufferedStreamer creates a BufferedStreamer that pre-fills // NewBufferedStreamer creates a BufferedStreamer that pre-fills
// bufferSize samples from source via a background goroutine. // bufferSize samples from source via a background goroutine.
// A typical bufferSize is 2× the sample rate (~2 seconds of audio). // A typical bufferSize is 2× the sample rate (~2 seconds of audio).
@@ -54,8 +92,24 @@ func NewBufferedStreamer(
return bs return bs
} }
// finish marks the stream ended, recording err as the reason when
// there is one. Every exit from readAhead goes through it: an exit
// that leaves done false strands Stream in its underrun branch,
// where it returns silence and ok forever.
func (bs *BufferedStreamer) finish(err error) {
bs.mu.Lock()
defer bs.mu.Unlock()
bs.done = true
if err != nil && bs.err == nil {
bs.err = err
}
}
// readAhead continuously reads from the source into the ring buffer // readAhead continuously reads from the source into the ring buffer
// until the source is drained, an error occurs, or Close is called. // until the source is drained, an error occurs, or Close is called.
// It always marks the stream done on the way out.
func (bs *BufferedStreamer) readAhead() { func (bs *BufferedStreamer) readAhead() {
// Temporary buffer for reading from source outside the lock. // Temporary buffer for reading from source outside the lock.
// 512 samples per chunk keeps the critical section short. // 512 samples per chunk keeps the critical section short.
@@ -63,6 +117,13 @@ func (bs *BufferedStreamer) readAhead() {
tmp := make([][2]float64, chunkSize) tmp := make([][2]float64, chunkSize)
// Every exit marks the stream done. An exit that does not is what
// stranded Stream in its underrun branch, returning silence and ok
// for the rest of the process's life.
var exitErr error
defer func() { bs.finish(exitErr) }()
for { for {
// Check if closed. // Check if closed.
select { select {
@@ -72,6 +133,15 @@ func (bs *BufferedStreamer) readAhead() {
} }
bs.mu.Lock() bs.mu.Lock()
// Stream gave up waiting for us. Nothing downstream is
// listening any more, so filling the ring is work for nobody.
if bs.done {
bs.mu.Unlock()
return
}
space := len(bs.ring) - bs.count space := len(bs.ring) - bs.count
if space == 0 { if space == 0 {
@@ -115,14 +185,12 @@ func (bs *BufferedStreamer) readAhead() {
} }
if !ok { if !ok {
bs.mu.Lock() // A drained source and a failed one both land here and are
bs.done = true // not the same event: one is a track that ended, the other
// is a track that broke. Err is what tells them apart, and
if srcErr := bs.source.Err(); srcErr != nil { // it is why the player must ask before treating this as a
bs.err = srcErr // natural finish.
} exitErr = bs.source.Err()
bs.mu.Unlock()
return return
} }
@@ -154,7 +222,27 @@ func (bs *BufferedStreamer) Stream(
} }
if bs.count == 0 { if bs.count == 0 {
// Buffer temporarily empty — fill with silence. // The read-ahead has not caught up. Silence buys it time --
// but only for a bounded stretch, because "forever" is
// reported upward as healthy playback and there is no watchdog
// above this to notice otherwise.
bs.starved++
if bs.starvedSince.IsZero() {
bs.starvedSince = time.Now()
}
if bs.starved >= minStarvedCalls &&
time.Since(bs.starvedSince) > maxStarvedDuration {
bs.done = true
if bs.err == nil {
bs.err = errSourceStalled
}
return 0, false
}
for i := range samples { for i := range samples {
samples[i] = [2]float64{} samples[i] = [2]float64{}
} }
@@ -162,6 +250,9 @@ func (bs *BufferedStreamer) Stream(
return len(samples), true return len(samples), true
} }
// Samples arrived, so whatever the stall was, it is over.
bs.resetStarvationLocked()
// Copy available samples from ring buffer. // Copy available samples from ring buffer.
n := len(samples) n := len(samples)
if n > bs.count { if n > bs.count {
@@ -197,6 +288,19 @@ func (bs *BufferedStreamer) Flush() {
bs.readPos = 0 bs.readPos = 0
bs.writPos = 0 bs.writPos = 0
bs.count = 0 bs.count = 0
// A seek empties the ring on purpose, and the refill that follows
// is exactly the stall the budget exists to tolerate. Charging it
// against a budget the previous underrun already spent would end
// the track on a seek near the end of a slow file.
bs.resetStarvationLocked()
}
// resetStarvationLocked forgets an underrun run. Must be called with
// bs.mu held.
func (bs *BufferedStreamer) resetStarvationLocked() {
bs.starved = 0
bs.starvedSince = time.Time{}
} }
// LockSource blocks the read-ahead goroutine from touching the // LockSource blocks the read-ahead goroutine from touching the
+213
View File
@@ -0,0 +1,213 @@
package player
import (
"log/slog"
"testing"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/events"
"yellowjacket/internal/testfixtures"
)
// fixtureSampleRate is what cmd/gentestdata writes (audio.go). It is
// deliberately not the speaker rate, which is what lets these tests
// tell the decoder's format from the player's default.
const fixtureSampleRate = 22050
// newTestPlayer is a player with a context and no database, so the
// track-metadata lookup cannot succeed.
func newTestPlayer(t *testing.T) *Player {
t.Helper()
p := NewPlayer(slog.Default(), nil)
rec := events.NewRecorder()
_ = p.ServiceStartup(
events.WithSink(t.Context(), rec),
application.ServiceOptions{},
)
return p
}
// loadFileLocked needs no speaker: it decodes, builds the chain and
// registers it paused. speaker.Play on an uninitialised device is
// what the integration guard elsewhere is about, so these assert on
// the state the load computed rather than on playback.
// p.format used to be assigned once, in the constructor, to the
// *speaker's* rate -- so it claimed 44.1 kHz for every file ever
// loaded. Play()'s replay-after-finish path resamples from it, so a
// finished track played again was resampled from a rate the decoder
// never produced: audibly the wrong speed and pitch, and wrong
// length and position arithmetic with it.
//
// The fixtures are 22050 Hz, which is exactly the point -- any of
// them disagrees with the speaker rate.
func TestLoadRecordsTheDecodersOwnFormat(t *testing.T) {
m := testfixtures.Load(t)
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
p := newTestPlayer(t)
if got := p.format.SampleRate; got != speakerSampleRate {
t.Fatalf(
"precondition: a fresh player should hold the speaker "+
"rate, got %d",
got,
)
}
if err := p.LoadFile(path); err != nil {
t.Fatalf("LoadFile(%s): %v", path, err)
}
if p.format.SampleRate == speakerSampleRate {
t.Fatalf(
"p.format still holds the speaker rate (%d) after "+
"loading a %d Hz file: the replay path would "+
"resample from the wrong rate",
speakerSampleRate, fixtureSampleRate,
)
}
if got := int(p.format.SampleRate); got != fixtureSampleRate {
t.Errorf(
"expected the decoder's rate %d, got %d",
fixtureSampleRate, got,
)
}
}
// trackLengthMs is written only when the database has a row for the
// file and cleared only by UnloadTrack, so a track with no row used
// to inherit whatever the last track's duration was -- and every
// position report is scaled by it, so the whole seek bar was then
// reporting one track's progress on another track's scale.
//
// There is no database here, so the lookup cannot succeed: exactly
// the case that used to inherit.
func TestLoadDoesNotInheritThePreviousTracksDuration(t *testing.T) {
m := testfixtures.Load(t)
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
p := newTestPlayer(t)
// Stand in for a previous track whose duration was resolved.
p.trackLengthMs = 9_999_000
if err := p.LoadFile(path); err != nil {
t.Fatalf("LoadFile(%s): %v", path, err)
}
if p.trackLengthMs == 9_999_000 {
t.Fatal(
"the previous track's duration survived the load: every " +
"position report for this track would be scaled by it",
)
}
}
// A new chain supersedes the old one's pending finished callback.
// Without this, a callback that queued for p.mu behind a LoadFile
// woke up and rewound, stopped and auto-advanced the *new* track.
func TestANewChainSupersedesTheOldFinishedCallback(t *testing.T) {
m := testfixtures.Load(t)
paths := m.Case(t, testfixtures.CaseCoverDedup)
if len(paths) < 2 {
t.Skip("need two fixture tracks")
}
p := newTestPlayer(t)
if err := p.LoadFile(paths[0]); err != nil {
t.Fatalf("LoadFile(%s): %v", paths[0], err)
}
stale := p.chainID
if err := p.LoadFile(paths[1]); err != nil {
t.Fatalf("LoadFile(%s): %v", paths[1], err)
}
if p.chainID == stale {
t.Fatal("loading a second file did not supersede the chain")
}
called := false
p.SetPlaybackFinishedHandler(func(error) { called = true })
// The first track's callback, arriving late.
p.onPlaybackFinished(stale, nil)
if called {
t.Error(
"a superseded chain's callback drove auto-advance: the " +
"track that is loaded now would be skipped",
)
}
if p.state == Stopped {
t.Error(
"a superseded chain's callback stopped the current track",
)
}
}
// The decoder is read by the read-ahead goroutine and by every
// position emit, and those used to be guarded by different mutexes:
// the read by srcMu, the position by the speaker lock, which
// read-ahead never takes. Under -race this failed on the emit that
// LoadFile itself makes.
//
// It needs the read-ahead goroutine to actually be running, so it
// keeps asking for the position for long enough to overlap it.
func TestPositionReadsDoNotRaceTheReadAhead(t *testing.T) {
m := testfixtures.Load(t)
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
p := newTestPlayer(t)
if err := p.LoadFile(path); err != nil {
t.Fatalf("LoadFile(%s): %v", path, err)
}
for range 200 {
if _, err := p.CurrentPositionSeconds(); err != nil {
t.Fatalf("CurrentPositionSeconds: %v", err)
}
}
}
// Seeking emits the landing position, and that emit reads the
// decoder -- so the source lock the seek holds must be released
// before it. A reentrant take here is a deadlock, not a failure,
// which is why this test exists rather than a comment.
func TestSeekEmitsWithoutDeadlocking(t *testing.T) {
m := testfixtures.Load(t)
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
p := newTestPlayer(t)
if err := p.LoadFile(path); err != nil {
t.Fatalf("LoadFile(%s): %v", path, err)
}
done := make(chan struct{})
go func() {
defer close(done)
_ = p.Seek(1)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Seek deadlocked: the position emit re-took the source lock")
}
}
+227 -41
View File
@@ -52,9 +52,17 @@ type Player struct {
control *beep.Ctrl control *beep.Ctrl
volume *effects.Volume volume *effects.Volume
speakerStreamer beep.Streamer speakerStreamer beep.Streamer
playbackFinishedHandler func() playbackFinishedHandler func(error)
trackChangeID uint64 trackChangeID uint64
mediaControls mediacontrols.Handler
// chainID identifies the streamer chain currently registered with
// the speaker. updateStreamers bumps it, and the finished
// callback carries the value it was registered with, so a callback
// that queued for p.mu behind a LoadFile can tell that the player
// has moved on and return rather than rewinding somebody else's
// track.
chainID uint64
mediaControls mediacontrols.Handler
// duckAmount is the attenuation currently applied on top of the // duckAmount is the attenuation currently applied on top of the
// user's volume, in the same base-2 exponent effects.Volume uses. // user's volume, in the same base-2 exponent effects.Volume uses.
@@ -63,6 +71,19 @@ type Player struct {
// not something the user chose. // not something the user chose.
duckAmount float64 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 // trackLengthMs holds the authoritative track duration in
// milliseconds, sourced from the database (which uses the // milliseconds, sourced from the database (which uses the
// custom header parser). The go-mp3 decoder's Len() can be // custom header parser). The go-mp3 decoder's Len() can be
@@ -148,6 +169,8 @@ func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
logger: logger, logger: logger,
db: db, db: db,
state: Stopped, state: Stopped,
systemVolume: platformOwnsVolume,
storedVolume: DefaultUserVol,
baseStreamer: generators.Silence(-1), baseStreamer: generators.Silence(-1),
format: beep.Format{ format: beep.Format{
SampleRate: speakerSampleRate, SampleRate: speakerSampleRate,
@@ -180,11 +203,18 @@ func (p *Player) InitSpeaker() error {
} }
// SetPlaybackFinishedHandler sets a callback invoked when a track // SetPlaybackFinishedHandler sets a callback invoked when a track
// finishes naturally. This allows the queue to drive auto-advance // stops streaming. This allows the queue to drive auto-advance
// without circular imports. // without circular imports.
// //
// The error says *why* the track stopped: nil for a track that
// reached its end, non-nil for one that broke partway through. Both
// arrive here because both look identical to the speaker, and only
// the queue holds the metadata a PlaybackFailed needs -- but they are
// not the same event, and reporting a decode failure as a natural
// finish is how a broken file used to auto-advance in silence.
//
//wails:ignore // internal wiring, not part of the app's IPC surface. //wails:ignore // internal wiring, not part of the app's IPC surface.
func (p *Player) SetPlaybackFinishedHandler(handler func()) { func (p *Player) SetPlaybackFinishedHandler(handler func(error)) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -424,6 +454,19 @@ func (p *Player) updateStreamers(
newBaseStreamer beep.StreamSeeker, newBaseStreamer beep.StreamSeeker,
sr beep.SampleRate, sr beep.SampleRate,
) error { ) error {
// A new chain supersedes the old one, so any finished callback the
// old one still owes is stale from here on.
p.chainID++
// The previous read-ahead goroutine reads the same decoder this
// one is about to, under its own srcMu -- two goroutines, two
// mutexes, one decoder that is not safe for concurrent use. The
// replay-after-finish path rebuilds from p.seeker without going
// through LoadFile, which is where that pair could meet.
if p.buffered != nil {
p.buffered.Close()
}
// set base streamer // set base streamer
p.baseStreamer = newBaseStreamer p.baseStreamer = newBaseStreamer
p.seeker = newBaseStreamer p.seeker = newBaseStreamer
@@ -474,23 +517,57 @@ func (p *Player) startPaused() {
p.control.Paused = true p.control.Paused = true
speaker.Unlock() speaker.Unlock()
// Captured, not read at callback time: by then p.chainID names
// whatever is loaded *now*, which is the thing the guard exists to
// distinguish this chain from.
chainID := p.chainID
buffered := p.buffered
// The beep.Callback runs with the speaker mutex held, so we // The beep.Callback runs with the speaker mutex held, so we
// dispatch to a goroutine that can safely acquire p.mu. // dispatch to a goroutine that can safely acquire p.mu.
speaker.Play(beep.Seq( speaker.Play(beep.Seq(
p.speakerStreamer, p.speakerStreamer,
beep.Callback(func() { beep.Callback(func() {
go p.onPlaybackFinished() // Asked here rather than under p.mu: this is the chain that
// just ended, and by the time the goroutine holds the lock
// p.buffered may be a different one.
var err error
if buffered != nil {
err = buffered.Err()
}
go p.onPlaybackFinished(chainID, err)
}), }),
)) ))
p.state = Paused p.state = Paused
} }
// onPlaybackFinished handles the natural end of a track. It is // onPlaybackFinished handles a track that stopped streaming, whether
// called on a new goroutine from the beep callback (which holds // it ended or broke. It is called on a new goroutine from the beep
// the speaker lock) so that it can safely acquire p.mu. // callback (which holds the speaker lock) so that it can safely
func (p *Player) onPlaybackFinished() { // acquire p.mu.
//
// chainID names the streamer chain the callback fired for and srcErr
// says why it stopped.
func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
p.mu.Lock() p.mu.Lock()
// The player has moved on while this callback queued for the lock
// -- a user pressing Next during the last second of a track is
// enough. Everything below is about the *current* track: rewinding
// the decoder, saying playback stopped, asking the queue to
// advance. Doing any of it now would do it to the wrong track.
if chainID != p.chainID {
p.mu.Unlock()
p.logger.Debug(
"Ignoring finished callback for a superseded chain",
"chain", chainID, "current", p.chainID,
)
return
}
p.state = Stopped p.state = Stopped
handler := p.playbackFinishedHandler handler := p.playbackFinishedHandler
mc := p.mediaControls mc := p.mediaControls
@@ -501,10 +578,11 @@ func (p *Player) onPlaybackFinished() {
// the Stopped state anyway, so this only moves the decoder. // the Stopped state anyway, so this only moves the decoder.
p.rewindLocked() p.rewindLocked()
p.emitPositionLocked() p.emitPositionLocked()
p.mu.Unlock()
// Emit Wails events outside the lock — these are non-blocking // Emitted under p.mu, like every other transition in this file.
// calls that don't need player state. // Outside it, a Play() taking the lock in the gap emits `playing`
// first and this stale `stopped` lands last -- leaving the button
// showing play over a track that is audibly running.
p.emitPlaybackFinished() p.emitPlaybackFinished()
events.Emit( events.Emit(
@@ -513,6 +591,8 @@ func (p *Player) onPlaybackFinished() {
map[string]string{"state": string(Stopped)}, map[string]string{"state": string(Stopped)},
) )
p.mu.Unlock()
// Notify media controls outside the lock. The track just // Notify media controls outside the lock. The track just
// ended so position is 0. // ended so position is 0.
if mc != nil { if mc != nil {
@@ -521,12 +601,19 @@ func (p *Player) onPlaybackFinished() {
) )
} }
p.logger.Info("Playback finished naturally") if srcErr != nil {
p.logger.Error(
"Playback stopped: the audio source failed",
"err", srcErr,
)
} else {
p.logger.Info("Playback finished naturally")
}
// Notify queue for auto-advance. Called without p.mu held // Notify queue for auto-advance. Called without p.mu held
// because it re-enters the player via LoadFile/Play. // because it re-enters the player via LoadFile/Play.
if handler != nil { if handler != nil {
handler() handler(srcErr)
} }
} }
@@ -587,6 +674,18 @@ func (p *Player) loadFileLocked(filePath string) error {
p.currentFile = f p.currentFile = f
// The decoder's own format, kept for the paths that rebuild the
// chain later: Play()'s replay branch resamples from it, so a
// stale rate there plays a finished track back at the wrong speed.
p.format = format
// The previous track's duration must not outlive it. This is set
// again by emitTrackChanged below, but only when the database has
// a row for the file -- and every position this player reports is
// scaled by it, so inheriting means every report is wrong by the
// ratio between two unrelated tracks.
p.trackLengthMs = 0
if err := p.updateStreamers( if err := p.updateStreamers(
streamer, format.SampleRate, streamer, format.SampleRate,
); err != nil { ); err != nil {
@@ -791,6 +890,10 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.systemVolume {
return
}
p.setVolumeLocked(desiredVolume) p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged() p.emitVolumeChanged()
p.saveState() p.saveState()
@@ -839,6 +942,10 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.systemVolume {
return nil
}
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume)) p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
p.emitVolumeChanged() p.emitVolumeChanged()
p.saveState() p.saveState()
@@ -869,6 +976,14 @@ func (p *Player) MuteToggle() error {
return errNoAudioFileLoaded 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() speaker.Lock()
p.volume.Silent = !p.volume.Silent p.volume.Silent = !p.volume.Silent
speaker.Unlock() speaker.Unlock()
@@ -906,6 +1021,8 @@ func (p *Player) CurrentPosition() (int, error) {
return 0, errNoAudioFileLoaded return 0, errNoAudioFileLoaded
} }
defer p.lockSourceLocked()()
speaker.Lock() speaker.Lock()
pos := math.Round( pos := math.Round(
100.0 * float64(p.seeker.Position()) / 100.0 * float64(p.seeker.Position()) /
@@ -924,6 +1041,28 @@ func (p *Player) Seek(targetSeconds int) error {
return p.seekLocked(targetSeconds) return p.seekLocked(targetSeconds)
} }
// lockSourceLocked blocks the read-ahead goroutine from touching the
// decoder and returns the function that releases it, so a caller can
// `defer p.lockSourceLocked()()`.
//
// Reading the decoder's position is a read *of the decoder*, and the
// speaker lock does not exclude the read-ahead goroutine -- it never
// takes it. That was a genuine data race on every position emit,
// once a second for the whole of playback.
//
// srcMu is not reentrant, so nothing that already holds it may call
// this; seekSourceLocked exists to keep that region free of emits.
// Must be called with p.mu held.
func (p *Player) lockSourceLocked() func() {
if p.buffered == nil {
return func() {}
}
p.buffered.LockSource()
return p.buffered.UnlockSource
}
// rewindLocked returns the decoder to the start of the track without // rewindLocked returns the decoder to the start of the track without
// touching playback state. Must be called with p.mu held. // touching playback state. Must be called with p.mu held.
func (p *Player) rewindLocked() { func (p *Player) rewindLocked() {
@@ -959,6 +1098,46 @@ func (p *Player) seekLocked(targetSeconds int) error {
return fmt.Errorf("cannot get track length: %w", err) return fmt.Errorf("cannot get track length: %w", err)
} }
// The source lock is released before anything below is emitted:
// emitPositionLocked reads the decoder's position and takes the
// same lock, which is not reentrant.
seekErr := p.seekSourceLocked(targetSeconds, lengthSecs)
if seekErr != nil {
p.logger.Warn(
"Seek failed, playback will start from "+
"the beginning",
"target-seconds", targetSeconds,
"err", seekErr,
)
// The optimistic move the UI already made has to be taken
// back, and only the backend knows it did not happen.
events.Emit(p.ctx, events.SeekFailed)
p.emitPositionLocked()
return fmt.Errorf("failed to seek: %w", seekErr)
}
if p.mediaControls != nil {
p.mediaControls.NotifySeek(targetSeconds)
}
// Report the landing position immediately rather than leaving the
// UI to guess until the next tick — this is the half of H-3 that
// desynced the seek bar by 30 s over four keyboard seeks.
p.emitPositionLocked()
return nil
}
// seekSourceLocked moves the decoder and flushes the stale read-ahead
// behind it. It owns the source lock for exactly that long and
// emits nothing, so its caller is free to read the position
// afterwards. Must be called with p.mu held.
func (p *Player) seekSourceLocked(
targetSeconds int,
lengthSecs int,
) error {
// Block the read-ahead goroutine from reading the source while // Block the read-ahead goroutine from reading the source while
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is // we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
// not safe for concurrent Read+Seek, and read-ahead runs on its // not safe for concurrent Read+Seek, and read-ahead runs on its
@@ -1014,19 +1193,11 @@ func (p *Player) seekLocked(targetSeconds int) error {
if seekErr != nil { if seekErr != nil {
speaker.Unlock() speaker.Unlock()
p.logger.Warn( p.logger.Debug(
"Seek failed, playback will start from "+ "seek rejected by the decoder",
"the beginning", "samples", samples, "err", seekErr,
"target-seconds", targetSeconds,
"samples", samples,
"err", seekErr,
) )
// The optimistic move the UI already made has to be taken
// back, and only the backend knows it did not happen.
events.Emit(p.ctx, events.SeekFailed)
p.emitPositionLocked()
return fmt.Errorf("failed to seek: %w", seekErr) return fmt.Errorf("failed to seek: %w", seekErr)
} }
@@ -1039,15 +1210,6 @@ func (p *Player) seekLocked(targetSeconds int) error {
p.buffered.Flush() p.buffered.Flush()
} }
if p.mediaControls != nil {
p.mediaControls.NotifySeek(targetSeconds)
}
// Report the landing position immediately rather than leaving the
// UI to guess until the next tick — this is the half of H-3 that
// desynced the seek bar by 30 s over four keyboard seeks.
p.emitPositionLocked()
return nil return nil
} }
@@ -1140,6 +1302,10 @@ func (p *Player) seekerLengthSecsLocked() (int, error) {
return 0, errNoAudioFileLoaded return 0, errNoAudioFileLoaded
} }
// Len is fixed for the life of the decoder, so unlike Position it
// races with nothing and needs no source lock -- which it must not
// take anyway: displayPositionSecsLocked calls this while holding
// it, and srcMu is not reentrant.
speaker.Lock() speaker.Lock()
length := p.seeker.Len() / int(p.format.SampleRate) length := p.seeker.Len() / int(p.format.SampleRate)
speaker.Unlock() speaker.Unlock()
@@ -1156,6 +1322,8 @@ func (p *Player) displayPositionSecsLocked() int {
return 0 return 0
} }
defer p.lockSourceLocked()()
speaker.Lock() speaker.Lock()
pos := p.seeker.Position() pos := p.seeker.Position()
total := p.seeker.Len() total := p.seeker.Len()
@@ -1266,7 +1434,15 @@ func (p *Player) saveState() {
volume := int64(DefaultUserVol) volume := int64(DefaultUserVol)
muted := false 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()) volume = int64(p.getUserVolume())
muted = p.volume.Silent muted = p.volume.Silent
} }
@@ -1350,11 +1526,20 @@ func (p *Player) restoreStateLocked() {
} }
} }
vol := clampVolume(UserVolume(state.Volume)) if p.systemVolume {
p.setVolumeLocked(vol) // 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 { if state.Muted {
p.volume.Silent = true p.volume.Silent = true
}
} }
// Restore last track if the file still exists. // Restore last track if the file still exists.
@@ -1394,8 +1579,9 @@ func (p *Player) restoreStateLocked() {
} }
p.logger.Info("Player state restored", p.logger.Info("Player state restored",
"volume", vol, "volume", p.getUserVolume(),
"muted", state.Muted, "muted", p.volume.Silent,
"systemVolume", p.systemVolume,
"trackPath", state.LastTrackPath, "trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds, "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])
}
}
}
+3 -3
View File
@@ -83,7 +83,7 @@ func TestFallback_TriggersOnNaturalFinish(t *testing.T) {
q.SetFallbackSource(fake) q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"}) q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
q.OnPlaybackFinished() q.OnPlaybackFinished(nil)
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved") waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool { waitUntil(t, func() bool {
@@ -159,7 +159,7 @@ func TestFallback_EmptyResultLeavesQueueExhausted(t *testing.T) {
q.SetFallbackSource(fake) q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{}) q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished() q.OnPlaybackFinished(nil)
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved") waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
@@ -193,7 +193,7 @@ func TestFallback_StaleResolutionDiscarded(t *testing.T) {
q.SetFallbackSource(fake) q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{}) q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished() // starts resolving, blocked on gate q.OnPlaybackFinished(nil) // starts resolving, blocked on gate
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
+78
View File
@@ -0,0 +1,78 @@
package queue
import (
"errors"
"testing"
"yellowjacket/backend/events"
)
// errTestDecode stands in for a decoder blowing up mid-track.
var errTestDecode = errors.New("decode blew up")
// currentIndex == -1 against a non-empty queue is a state this
// package produces on purpose: onQueueExhausted(false) sets it and
// deliberately leaves the finished track loaded in the player, so it
// stays on the now-playing bar. Pressing play from there and letting
// it finish re-enters OnPlaybackFinished with exactly that pair --
// which used to index q.tracks[-1] and panic, on a goroutine
// dispatched from the audio callback with no caller to recover it.
func TestFinishedWithNoCurrentTrackDoesNotPanic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
index int
}{
{"exhausted queue leaves -1", -1},
{"index past the end", 3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
q, _, _ := setupRecordedQueue(t)
q.tracks = []Track{
{FilePath: "/a.mp3"},
{FilePath: "/b.mp3"},
}
q.currentIndex = tt.index
// The assertion is that this returns at all.
q.OnPlaybackFinished(nil)
if q.currentIndex != tt.index {
t.Errorf(
"an out-of-range index was acted on: %d became %d",
tt.index, q.currentIndex,
)
}
})
}
}
// A track that broke mid-playback is not a track that was listened
// to. The player cannot say so itself -- the metadata is here -- so
// it hands the reason over and this is where it becomes a
// PlaybackFailed rather than a silent auto-advance.
func TestAFailedTrackIsReportedAndNotCountedAsAPlay(t *testing.T) {
t.Parallel()
q, _, rec := setupRecordedQueue(t)
q.tracks = []Track{
{FilePath: "/a.mp3", Title: "A", AudioFileID: 1},
{FilePath: "/b.mp3", Title: "B", AudioFileID: 2},
}
q.currentIndex = 0
q.OnPlaybackFinished(errTestDecode)
if _, ok := rec.Last(events.PlaybackFailed); !ok {
t.Errorf(
"a track that failed mid-playback told the user nothing; "+
"got %v",
rec.Names(),
)
}
}
+36 -9
View File
@@ -1,18 +1,45 @@
package queue package queue
// OnPlaybackFinished is called when a track finishes playing naturally. // OnPlaybackFinished is called when a track stops streaming. This
// This drives the auto-advance behavior and records the play. // drives the auto-advance behavior and records the play.
func (q *Queue) OnPlaybackFinished() { //
// srcErr says why the track stopped: nil for one that reached its
// end, non-nil for one that broke partway through. The player cannot
// tell the user which, because the metadata lives here -- so a failure
// is reported as PlaybackFailed and *not* recorded as a play, while
// the advance happens either way. Before this, a file that failed
// mid-track advanced in silence and was counted as listened to.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (q *Queue) OnPlaybackFinished(srcErr error) {
q.mu.Lock() q.mu.Lock()
if len(q.tracks) == 0 { // currentIndex is -1 whenever the queue has been exhausted, and
// onQueueExhausted deliberately leaves the finished track loaded
// in the player -- so a natural finish can re-enter here against a
// queue that is not empty and an index that is not valid. Every
// other path in this package bounds-checks before indexing; this
// one panicked, on a goroutine with no caller to recover it.
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
q.mu.Unlock() q.mu.Unlock()
return return
} }
// Capture the track that just finished before advancing. // Capture the track that just finished before advancing.
finishedID := q.tracks[q.currentIndex].AudioFileID finished := q.tracks[q.currentIndex]
finishedID := finished.AudioFileID
if srcErr != nil {
q.emitPlaybackFailed(finished, srcErr)
}
// A track that broke was not listened to.
recordFinished := func() {
if srcErr == nil {
q.recordPlay(finishedID)
}
}
// Repeat One: replay the current track. // Repeat One: replay the current track.
if q.repeatMode == RepeatOne { if q.repeatMode == RepeatOne {
@@ -21,7 +48,7 @@ func (q *Queue) OnPlaybackFinished() {
} }
q.mu.Unlock() q.mu.Unlock()
q.recordPlay(finishedID) recordFinished()
return return
} }
@@ -31,7 +58,7 @@ func (q *Queue) OnPlaybackFinished() {
// Queue exhausted — this is the extension point for a future fallback playlist. // Queue exhausted — this is the extension point for a future fallback playlist.
q.onQueueExhausted(false) q.onQueueExhausted(false)
q.mu.Unlock() q.mu.Unlock()
q.recordPlay(finishedID) recordFinished()
return return
} }
@@ -44,12 +71,12 @@ func (q *Queue) OnPlaybackFinished() {
if !q.playCurrentOrSkip(true, q.nextIndex) { if !q.playCurrentOrSkip(true, q.nextIndex) {
q.onQueueExhausted(false) q.onQueueExhausted(false)
q.mu.Unlock() q.mu.Unlock()
q.recordPlay(finishedID) recordFinished()
return return
} }
q.emitIndexChanged() q.emitIndexChanged()
q.mu.Unlock() q.mu.Unlock()
q.recordPlay(finishedID) recordFinished()
} }
+2 -2
View File
@@ -107,7 +107,7 @@ func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
// The first track finished: auto-advance lands on the missing // The first track finished: auto-advance lands on the missing
// file and must step over it rather than stopping dead. // file and must step over it rather than stopping dead.
q.OnPlaybackFinished() q.OnPlaybackFinished(nil)
if got := q.GetState().CurrentIndex; got != 2 { if got := q.GetState().CurrentIndex; got != 2 {
t.Errorf("currentIndex after skipping: got %d, want 2", got) t.Errorf("currentIndex after skipping: got %d, want 2", got)
@@ -183,7 +183,7 @@ func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
q.SetQueue(paths, 0, false, Source{}) q.SetQueue(paths, 0, false, Source{})
q.Play() q.Play()
q.OnPlaybackFinished() q.OnPlaybackFinished(nil)
if q.GetState().CurrentIndex != -1 { if q.GetState().CurrentIndex != -1 {
t.Errorf( t.Errorf(
+10 -1
View File
@@ -24,10 +24,19 @@ func DefaultBindings() map[string]string {
"player.repeat": "R", "player.repeat": "R",
"player.mute": "M", "player.mute": "M",
// Navigation (Global scope) // Navigation (Global scope). Back and forward are the browser's
// own combination on every platform, which is the whole design
// brief for them: the app has one global history and this is the
// gesture people already have for it. The modifier is what keeps
// them clear of `player.seekBack`/`seekForward`, which are the
// bare arrows -- a binding is matched on its full canonical
// string, so "Alt+Left" and "Left" are different keys and not a
// conflict.
"nav.search": "/", "nav.search": "/",
"nav.searchAlt": "Ctrl+F", "nav.searchAlt": "Ctrl+F",
"nav.queue": "Q", "nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions // App actions
"app.selectAll": "Ctrl+A", "app.selectAll": "Ctrl+A",
+25 -55
View File
@@ -4,18 +4,28 @@ includes:
common: ../Taskfile.yml common: ../Taskfile.yml
vars: vars:
# The *installed* package name, which every adb-driven task below uses # APP_ID is an *assertion*, not a setting, and it has no default.
# to uninstall, launch and filter. It must agree with `applicationId`
# in app/build.gradle, and nothing enforces that.
# #
# ANDROID.md says to set this in build/config.yml. That does not work # It used to be the id every adb-driven task below uninstalled,
# in beta.8, checked both ways: `wails3 task` builds its var set from # launched and filtered, defaulting to "app.yellowjacket". It could
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads # never have been a setting: `wails3 task` builds its var set from CLI
# config.yml -- and even when set it feeds only these adb commands, # KEY=VALUE arguments and the Taskfile tree only -- nothing reads
# never Gradle. So the identity is declared twice, here and in # build/config.yml, contrary to ANDROID.md, checked with --dry -- and
# build.gradle, and a change to one alone means the official run and # even when set it fed only the adb commands, never Gradle. So the
# deploy tasks address a package that is not installed. # identity was declared twice, here and as `applicationId` in
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}' # app/build.gradle, with nothing enforcing that they agree.
#
# They did not agree. The debug buildType carries
# `applicationIdSuffix ".dev"`, so the tasks that assemble a debug APK
# addressed the *release* id -- on a device, the user's installed app
# and their library (#159).
#
# The id is now read back from the built APK by scripts/android-
# pkgid.sh, so the thing installed and the thing launched agree by
# construction. Passing APP_ID= says "this build had better declare
# that id", and the deploy refuses before touching anything if it does
# not -- which is the check that would have caught #159 statically.
APP_ID: '{{.APP_ID | default ""}}'
MIN_SDK: '21' MIN_SDK: '21'
TARGET_SDK: '35' TARGET_SDK: '35'
# The emulator runs the host architecture; physical devices are arm64 # The emulator runs the host architecture; physical devices are arm64
@@ -372,9 +382,7 @@ tasks:
ARCH: '{{.ARCH | default .HOST_ARCH}}' ARCH: '{{.ARCH | default .HOST_ARCH}}'
cmds: cmds:
- task: ensure-emulator - task: ensure-emulator
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
run: run:
summary: Build, install and launch a debug build in the Android Emulator summary: Build, install and launch a debug build in the Android Emulator
@@ -383,9 +391,7 @@ tasks:
- task: build - task: build
cmds: cmds:
- task: assemble:apk - task: assemble:apk
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true' - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
device:list: device:list:
summary: Lists connected Android devices and emulators (serials) summary: Lists connected Android devices and emulators (serials)
@@ -400,25 +406,7 @@ tasks:
ARCH: arm64 ARCH: arm64
cmds: cmds:
- task: assemble:apk - task: assemble:apk
- | - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions: preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb' - sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -430,25 +418,7 @@ tasks:
vars: vars:
ARCH: arm64 ARCH: arm64
cmds: cmds:
- | - './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
DEVICE='{{.DEVICE_ID | default ""}}'
if [ -z "$DEVICE" ]; then
DEVICE="${DEVICE_ID:-}"
fi
if [ -z "$DEVICE" ]; then
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
fi
if [ -z "$DEVICE" ]; then
echo "Error: no connected physical Android device found."
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
echo "Find connected device serials with: {{.ADB}} devices"
exit 1
fi
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
preconditions: preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb' - sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)" msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -891,13 +891,41 @@ public class MainActivity extends AppCompatActivity {
} }
} }
/**
* The activity going away is not the app shutting down.
*
* <p>The scaffold called {@code bridge.shutdown()} here, which is
* the natural reading of onDestroy and is wrong for this app twice
* over. Android destroys and recreates an activity for a
* configuration change the manifest does not declare, under memory
* pressure, and on every background if the user has "Don't keep
* activities" on -- all **without restarting the process**. And
* when the user really does leave, this app's reason for existing
* in the background is that a song is playing, which is what the
* {@code mediaPlayback} foreground service is holding the process
* alive for. Either way, tearing the Go side down here would stop
* the music.
*
* <p>It was harmless only by accident: {@code nativeShutdown} calls
* {@code App.Quit()}, whose Android {@code destroy()} is an empty
* method, and {@code Run()}'s deferred {@code shutdownServices()}
* can never fire because Android's {@code platformRun} is
* {@code select{}} and does not return. So no {@code
* ServiceShutdown} has ever run on Android, and removing this call
* changes nothing today -- it stops the day someone implements
* {@code destroy()} from silently killing playback on a rotation.
*
* <p>There is no callback for "the process is going away"; Android
* simply kills it. Durability on this platform is the persist
* writers, which submit on every mutation rather than at exit.
*
* <p>See #52, and CLAUDE.md, "An activity is a view onto the
* process".
*/
@Override @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
unregisterSystemEventReceivers(); unregisterSystemEventReceivers();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) { if (webView != null) {
webView.destroy(); webView.destroy();
} }
@@ -129,7 +129,24 @@ public class WailsBridge {
} }
/** /**
* Initialize the native Go library * Initialize the native Go library.
*
* <p><b>{@code initialized} is deliberately per-instance, and making
* it {@code static} is the trap this comment exists for.</b> A
* recreated activity builds a new bridge and calls this again, in a
* process where the native library is already loaded and Go's
* {@code main()} is already running -- so "initialise once per
* process" looks like exactly the right rule. It is not, because
* {@code nativeInit} does <i>two</i> things: it runs
* {@code go mainFunc()}, and it stores the global JNI reference to
* <i>this</i> bridge. Skip it and Go keeps executing JavaScript
* against the destroyed activity's WebView: the app opens, renders,
* and never receives another backend event.
*
* <p>So this is called every time, and the half that must not repeat
* is latched on the Go side instead, at the top of {@code main()} --
* which is also where the damage was ({@code os.Exit(1)}), and the
* only place that can see it. See #52.
*/ */
public void initialize() { public void initialize() {
if (initialized) { if (initialized) {
+58 -24
View File
@@ -1,6 +1,12 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
/**
* How far the scroll test scrolls. One constant, because the guard and
* the assertion have to agree about it they did not, which is #133.
*/
const SCROLL_TARGET = 80;
/** /**
* Plan 007 phase 5: expanding an album shows its tracks. * Plan 007 phase 5: expanding an album shows its tracks.
* *
@@ -104,20 +110,30 @@ test.describe('the album dropdown', () => {
await app.setViewportSize({ width: 900, height: 600 }); await app.setViewportSize({ width: 900, height: 600 });
try { try {
await expect.poll(() => scrollRange(app)).toMatchObject({ // The container has to be a scroller at all, which is the thing
scrollable: true, // the defect behind this spec broke and is a property rather
overflowY: 'auto', // than a moment.
}); await expect
.poll(() => scrollRange(app))
.toMatchObject({ overflowY: 'auto' });
await app.evaluate(() => { // **Scrolling it and reading it back are one round trip** (#151).
const sc = document //
.querySelector('cover-grid') // #133 made the guard ask for the range this needs rather than
?.shadowRoot?.querySelector('.grid-scroll-container'); // for "scrollable at all", which was necessary and is not
// sufficient: a guard and the write it guards are separate
if (sc) sc.scrollTop = 80; // `evaluate` calls, so the grid can satisfy the range and settle
}); // out of it before the write lands. It still does — observed as
// `Expected 80, Received 10` in the second of three consecutive
expect(await scrollTop(app)).toBe(80); // full-suite runs, with the spec green alone on the same app
// straight afterwards.
//
// Polling harder cannot close a window between two moments; only
// removing the window can. So the probe sets `scrollTop` and
// returns what it reads back, in one page-side call, and the
// poll retries *that* — which also means the assertion is about
// what the grid did rather than about what it was ready to do.
await expect.poll(() => scrollTo(app, SCROLL_TARGET)).toBe(SCROLL_TARGET);
// And the dropdown it opens is on screen, wherever the manager // And the dropdown it opens is on screen, wherever the manager
// decides that leaves the scroll. It is *not* "the position is // decides that leaves the scroll. It is *not* "the position is
@@ -248,27 +264,45 @@ async function closeDropdown(app: Page): Promise<void> {
}); });
} }
/** Whether the grid can scroll at all, which decides if a probe can move. */ /** Whether the grid is a scroller at all, which is what the bug broke. */
async function scrollRange(app: Page) { async function scrollRange(app: Page) {
return app.evaluate(() => { return app.evaluate((target) => {
const sc = document const sc = document
.querySelector('cover-grid') .querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container'); ?.shadowRoot?.querySelector('.grid-scroll-container');
return { return {
scrollable: !!sc && sc.scrollHeight > sc.clientHeight + 40, // Reported for the failure message rather than waited on: `room`
// was the guard #133 strengthened, and #151 is that a guard in
// its own round trip cannot speak for the write in the next one.
// `scrollTo` below is the assertion now; this says *why* it did
// not reach the target when it does not.
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
overflowY: sc ? getComputedStyle(sc).overflowY : '', overflowY: sc ? getComputedStyle(sc).overflowY : '',
}; };
}); }, SCROLL_TARGET);
} }
async function scrollTop(app: Page): Promise<number> { /**
return app.evaluate( * Scroll the grid and report where it actually landed, in one call.
() => *
document * The whole point is that the set and the read share a moment: a
.querySelector('cover-grid') * `scrollTop` write is clamped to the range *at the instant it lands*,
?.shadowRoot?.querySelector('.grid-scroll-container')?.scrollTop ?? -1, * so reading it back in a second round trip asks a container that may
); * have re-laid out in between.
*/
async function scrollTo(app: Page, target: number): Promise<number> {
return app.evaluate((to) => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
if (!sc) return -1;
sc.scrollTop = to;
return sc.scrollTop;
}, target);
} }
/** Whether the open dropdown is inside the scroll container's viewport. */ /** Whether the open dropdown is inside the scroll container's viewport. */
+244
View File
@@ -15,12 +15,49 @@ import { test, expect } from '../support/fixtures.js';
* *
* What it cannot answer is whether Android's *gesture* reaches the * What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold. * WebView, which is between the OS and the scaffold.
*
* **And `data-active-view` is not the behaviour.** Every assertion here
* used to be that attribute, which the shell sets on every path
* including `_isBack` so this file was green throughout #72, in
* which both navs highlighted the view the user had just *left*. The
* shell's own bookkeeping was the one thing that was already right;
* what a person sees is `aria-current`, and that is asserted below as
* well. This is the same trap `layout-overflow.spec.ts` set for #69: a
* spec named for the behaviour, measuring the plumbing.
*/ */
type Page = import('@playwright/test').Page; type Page = import('@playwright/test').Page;
const activeView = (page: Page) => const activeView = (page: Page) =>
page.getByTestId('main-content'); page.getByTestId('main-content');
/** A common phone, where the bottom bar is the primary navigation. */
const PHONE = { width: 390, height: 844 };
/**
* The nav item for a destination, in whichever navigation is on screen.
*
* Both navs carry a button named `Albums`, and only one of them is ever
* in the accessibility tree the other is `display: none` so the
* role query resolves to the one the user can see at this viewport.
* That is the point: the highlight has to be right in both, and #72 was
* two different-looking symptoms of one cause.
*/
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/**
* `aria-current="page"` is the accessible fact and the assertion worth
* making; `.active` is a class and could be restyled without breaking
* anything real.
*/
async function expectHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page');
}
async function expectNotHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false');
}
/** /**
* Open an artist's detail view, which is the deepest ordinary route. * Open an artist's detail view, which is the deepest ordinary route.
* *
@@ -42,6 +79,114 @@ async function openAnArtist(app: Page): Promise<void> {
); );
} }
/**
* The global back/forward control (#6).
*
* It is desktop chrome hidden below 900px, where the sidebar has
* already given up its labels so these set a desktop viewport
* explicitly rather than trusting the runner's default.
*/
const DESKTOP = { width: 1280, height: 800 };
const backButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Back' });
const forwardButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Forward' });
test.describe('global back and forward', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('offers nothing at launch, in either direction', async ({ app }) => {
// The launch entry is *replaced*, not pushed, so there is nothing
// of ours behind it — and a Back button that is live at the root
// is a press that does nothing on desktop and, on Android, the
// press that should have exited the app (#142). This assertion is
// what pins that: it failed before the launch navigation stopped
// recording two entries.
await expect(backButton(app)).toBeDisabled();
await expect(forwardButton(app)).toBeDisabled();
});
test('walks the history in both directions, and says which are available', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeDisabled();
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
// Standing in the middle of the list: both directions live, which
// is the state a single depth counter cannot express.
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeEnabled();
await forwardButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await expect(forwardButton(app)).toBeDisabled();
});
test('reaches the detail view a tab click left behind', async ({ app }) => {
// The report, exactly: the album is one entry away the whole time,
// and before this control the only way back to it was a button
// that had gone off screen with the view it belonged to.
await app.getByTestId('nav-artists').click();
await openAnArtist(app);
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
});
test('drops the forward list when the user navigates from the middle', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await app.getByTestId('nav-tracks').click();
await backButton(app).click();
await expect(forwardButton(app)).toBeEnabled();
// A browser truncates here, and so does this: what was ahead is no
// longer reachable, and a Forward button still offering it would
// be pointing at an entry that has been overwritten.
await app.getByTestId('nav-genres').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres');
await expect(forwardButton(app)).toBeDisabled();
await expect(backButton(app)).toBeEnabled();
});
test('is absent below the desktop band, where nothing needs it', async ({
app,
}) => {
// Alt+Left/Right survive at every width, the detail views keep
// their own back buttons and the phone has the platform's gesture
// — so this is a control standing down, not an action becoming
// unreachable. It is hidden at 899 because the top bar is what
// runs out of room first below 900 (#143).
await app.setViewportSize({ width: 899, height: 600 });
await expect(app.locator('nav-history')).toBeHidden();
await app.setViewportSize({ width: 390, height: 844 });
await expect(app.locator('nav-history')).toBeHidden();
});
});
test.describe('the back gesture', () => { test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({ test('leaves a detail view for the view it was opened from', async ({
app, app,
@@ -71,6 +216,105 @@ test.describe('the back gesture', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
}); });
test('leaves the nav highlighting the view it landed on, not the one it left', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expectHighlighted(app, 'Albums');
await app.getByTestId('nav-tracks').click();
await expectHighlighted(app, 'Tracks');
await app.goBack();
// #72, and the half of it the report did not describe: this is
// desktop, and before the shell published the active view *both*
// navs stayed on Tracks. An absent highlight reads as a glitch; a
// confident wrong one is worse, and any back across two primary
// views produced it.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
await expectNotHighlighted(app, 'Tracks');
});
test('keeps the parent destination lit while a detail view is open', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expectHighlighted(app, 'Artists');
await openAnArtist(app);
// A detail view is not a destination in either nav, and the user is
// still inside Artists. `app-sidebar` did this by accident -- it
// guarded on its own item list, so an unmatched name left the
// highlight alone -- and that accident is why the sidebar looked
// right on a detail view while the tab bar lit nothing. This test
// therefore passed before the fix and is here to keep the rule from
// being lost while the others are made to pass; the *tab bar's*
// half of it is the phone test below, which did not.
await expectHighlighted(app, 'Artists');
await app.goBack();
await expectHighlighted(app, 'Artists');
});
test('the tab bar survives the same journey on a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
// The reported shape: Albums, open an album, press back. The tab
// bar had a highlight, then no highlight at all, and never got it
// back — `bottom-nav` took the detail view's name, matched it
// against no tab, and lit nothing.
await navItem(app, 'Albums').click();
await expectHighlighted(app, 'Albums');
await app.locator('cover-grid').getByText('Glass Harbour').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expectHighlighted(app, 'Albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
});
test('the drawer sidebar opens on the page you are standing on', async ({
app,
}) => {
await app.setViewportSize(PHONE);
await navItem(app, 'Tracks').click();
await expectHighlighted(app, 'Tracks');
// A third symptom of the same cause, found while measuring #72 and
// not in the report: `bottom-nav` mounts its `<app-sidebar>` when
// the drawer opens, so that copy had heard no `navigate` at all and
// showed its own default — Home, from any page in the app. An event
// has no answer for a listener that was not there; a store does.
await navItem(app, 'More').click();
// The element carrying the testid is the `wa-drawer` host, which
// always reports hidden -- what is visible is the `<dialog>` in its
// shadow root -- so the drawer being open is asserted of the
// sidebar it holds rather than of itself.
const drawer = app.getByTestId('nav-drawer');
await expect(drawer.locator('app-sidebar')).toBeVisible();
await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute(
'aria-current',
'page',
);
await expect(drawer.getByTestId('nav-home')).toHaveAttribute(
'aria-current',
'false',
);
});
test('an in-app back button consumes exactly one entry', async ({ app }) => { test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click(); await app.getByTestId('nav-tracks').click();
await openAnArtist(app); await openAnArtist(app);
+147
View File
@@ -0,0 +1,147 @@
import { test, expect, callBinding, NO_QUEUE_SOURCE } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The bottom bar's two promises (#23, #42): the transport is centred in
* the window, and the volume is a slider rather than a popup.
*
* **"Centred" is measured against the window, not against the space
* left over**, which is the whole of #23. The bar was
* `320px 1fr auto`, so the transport sat in the middle of what the
* metadata and the queue button did not use its centre was ~140px
* right of the window's at every size, which reads as an alignment
* mistake rather than as a layout choice.
*
* The mechanism is that the outer two columns are the same width, so
* this asserts the *outcome* (centre lines up) rather than the CSS. A
* spec that checked `grid-template-columns` would pass on any build
* that kept the declaration and broke the result.
*/
/** Where the transport sits, against where the window's centre is. */
const geometry = (app: Page) =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('.bottom-bar')!;
const player = document.querySelector<HTMLElement>('audio-player')!;
const b = bar.getBoundingClientRect();
const p = player.getBoundingClientRect();
const seek = player.shadowRoot
?.querySelector('seek-bar')
?.shadowRoot?.querySelector('wa-slider');
return {
offset: Math.round(p.left + p.width / 2 - (b.left + b.width / 2)),
barHeight: Math.round(b.height),
seekWidth: seek ? Math.round(seek.getBoundingClientRect().width) : -1,
};
});
/** Something has to be playing before the transport draws a seek bar. */
async function play(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 }[];
return tracks.slice(0, 3).map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
await callBinding(app, 'queue.Queue.Play');
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
}
test.describe('the bottom bar', () => {
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Four widths, because a centring bug is a function of width: the old
* layout was off by half the difference between the two outer
* columns, so it was wrong by a different amount at each one and
* exactly right at none.
*/
for (const width of [800, 900, 1100, 1440]) {
test(`centres the transport in the window at ${width}px`, async ({
app,
}) => {
await app.setViewportSize({ width, height: 700 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.offset)).toBe(0);
});
}
/**
* The seek bar is what the centring is *paid for* with, so it is
* asserted rather than assumed.
*
* Reserving the metadata's full width on both sides centres the
* transport perfectly and squeezes the control you drag: measured
* during this work at **61px of track at 800px**, against 257 before
* the change. The side columns are capped at a quarter of the bar for
* that reason, and this is the number that says so 246 at 800px,
* which is parity with the uncentred layout.
*/
test('does not pay for the centring with the seek bar', async ({ app }) => {
await app.setViewportSize({ width: 800, height: 700 });
await play(app);
await expect
.poll(() => geometry(app).then((g) => g.seekWidth))
.toBeGreaterThan(200);
});
/**
* #42: the slider is simply there. Three gestures click open, drag,
* click closed is what a bottom bar has room not to ask for.
*/
test('shows the volume slider without a click', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
const volume = app.locator('.bottom-bar volume-control');
await expect(volume).toBeVisible();
await expect(volume.locator('wa-slider')).toBeVisible();
});
/**
* And the inline icon is the mute toggle, because with the slider
* beside it there is nothing left to disclose. The name follows the
* action rather than the state for the same reason.
*/
test('names the inline icon after what it does', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(
app.locator('.bottom-bar volume-control').getByRole('button', {
name: 'Mute',
}),
).toBeVisible();
});
/**
* The bar is a fixed 4em row and the transport sits in it. A slider
* with a label grows `#slider` by 8px unless `wa-slider-label.css`
* suppresses it, which moved the whole bar the last time so the
* height is pinned here rather than left to a screenshot.
*/
test('stays 4em tall', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.barHeight)).toBe(64);
});
});
+4 -9
View File
@@ -29,18 +29,13 @@ test.describe('a control says what it controls', () => {
}); });
test('the volume slider is announced as Volume', async ({ app }) => { test('the volume slider is announced as Volume', async ({ app }) => {
// The popup renders no slider at all while closed, the same way the // No disclosure to open first, and no state to put back afterwards:
// queue panel renders no list — so this has to open it first. // #42 made the slider inline, so it is simply there. The assertion
await app.getByRole('button', { name: /volume/i }).click(); // is unchanged — the *name* is the subject here, and the route to
// the control got shorter rather than different.
await expect( await expect(
app.getByRole('slider', { name: 'Volume' }), app.getByRole('slider', { name: 'Volume' }),
).toBeVisible(); ).toBeVisible();
// Leave the transport as it was found: the specs share one page in
// file order, and an open popup covers the buttons beneath it.
await app.keyboard.press('Escape');
await app.locator('body').click({ position: { x: 5, y: 5 } });
}); });
test('naming the slider did not move the transport', async ({ app }) => { test('naming the slider did not move the transport', async ({ app }) => {
+8 -1
View File
@@ -36,9 +36,16 @@ test.describe('a failed binding says so', () => {
// Libraries is the one section that starts expanded (H-22), so ask // Libraries is the one section that starts expanded (H-22), so ask
// the disclosure what state it is in rather than assuming one — a // the disclosure what state it is in rather than assuming one — a
// blind click used to expand it and now collapses it. // blind click used to expand it and now collapses it.
//
// By role and name, not by `.header`: since #27 the section also
// contains a `job-panel`, and an open `job-details-drawer` inside
// it carries the same class. That only bites once a job exists,
// which is why it showed up on the *second* engine of a CI run and
// not the first.
const disclosure = page const disclosure = page
.locator('config-section[heading="Libraries"]') .locator('config-section[heading="Libraries"]')
.locator('.header'); .getByRole('button', { name: 'Libraries' })
.first();
if ((await disclosure.getAttribute('aria-expanded')) === 'false') { if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
await disclosure.click(); await disclosure.click();
+318
View File
@@ -0,0 +1,318 @@
import { test, expect } from '../support/fixtures.js';
/**
* #69: the Playlists header's buttons could not be reached.
*
* Three text buttons Import (91px), New Playlist (122px), New Smart
* Playlist (162px), 390px in total inside a header that gets 700px at
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
* phone width the Android report was the plain version of it: you
* cannot scroll to reach them, and scrolling is not how page controls
* should be exposed anyway.
*
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
* this file exists rather than a case being added there. That spec
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
* component is invisible to it. So the measurement here is per-button
* and per-header, against the widths the app promises.
*
* Plan 018's size matrix is the promise being kept: **no action is ever
* unreachable at any supported size.** These are its three bands.
*/
const VIEWPORTS = [
// Desktop's worst case, and not the enforced minimum: the sidebar
// collapses to icons *below* 900, so the content area is 843px at 899
// and 700px at 900. Testing "the minimum" and stopping misses it.
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
{ name: '390×780 (phone)', width: 390, height: 780 },
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
];
/** Every action the Playlists header can offer, in declared order. */
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
/**
* What the header is actually rendering, measured rather than inferred.
*
* A shadow query is the wrong tool for *asserting* that is what
* `getByRole` below is for but it is the right one for a measurement,
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
const header = root.querySelector<HTMLElement>('.page-header')!;
const box = header.getBoundingClientRect();
const title = root.querySelector<HTMLElement>('h1')!;
const clipped = [
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
]
.filter((b) => !b.hidden)
.filter((b) => {
const r = b.getBoundingClientRect();
return r.right > box.right + 1 || r.left < box.left - 1;
})
.map((b) => b.dataset['actionId'] ?? 'more');
return {
overflow: header.scrollWidth - header.clientWidth,
clipped,
titleTruncated: title.scrollWidth > title.clientWidth + 1,
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? ''),
menu: [
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled: the fit is decided by a ResizeObserver, so it settles a
// frame after the resize rather than with it.
await expect
.poll(async () => (await headerFit(app))?.clipped)
.toEqual([]);
const fit = (await headerFit(app))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for all three. This is
// the assertion the issue asks for: not "it fits" but "nothing
// was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
});
}
/**
* The title gives way before an action does.
*
* Once the heading can ellipsis it absorbs the pressure, and
* `scrollWidth` then reports a header that fits perfectly while the
* heading reads "Playlis…" this issue's own failure mode moved from
* the button to the title, and invisible to exactly the measurement
* that missed it the first time. At the desktop sizes there is always
* an action to collapse instead.
*/
test('does not truncate the heading to keep a button', async ({ app }) => {
for (const vp of VIEWPORTS.slice(0, 2)) {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app))?.titleTruncated)
.toBe(false);
}
});
/**
* Asserted through the accessibility tree, never a shadow query. An
* overflow menu is exactly the shape that grows a nameless control,
* and this repo has shipped one four times most recently the
* queue's own close button.
*/
test('the overflow is a named control that opens a named menu', async ({
app,
}) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await expect(more).toHaveAttribute('aria-expanded', 'false');
await more.click();
await expect(more).toHaveAttribute('aria-expanded', 'true');
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// Collapsed at 900×600: Import (lowest priority) and New Smart
// Playlist. New Playlist stays a button because it is the drop
// target, and a closed menu cannot be one.
await expect(
menu.getByRole('menuitem', { name: 'Import' }),
).toBeVisible();
await expect(
app.getByRole('button', { name: 'New Playlist', exact: true }),
).toBeVisible();
});
/**
* The phone case is the original report. Every action is in the menu
* at 390px, and the menu is reachable by name which is the whole of
* "these need to be reachable in a sensible way".
*/
test('offers every action from the menu on a phone', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
for (const label of ACTIONS) {
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
}
});
/**
* Escape closes it and focus goes back to the trigger `MenuKeyboard`
* is shared with every other menu in the app precisely so this is not
* a second keyboard model, and this is what proves it was wired up
* rather than merely imported.
*/
test('takes the keyboard, and gives it back', async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// The first item takes focus on open. `wa-dropdown-item` sets its
// own role in its own first update, so this is polled rather than
// read: a query at the host's updateComplete finds nothing, which
// reads exactly like a menu that refused to take focus.
await expect
.poll(async () =>
app.evaluate(() => {
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
// on the *host* whose shadow root has no active element.
// Descending unconditionally lands inside the focused
// `wa-dropdown-item`'s own shadow root, where nothing is
// focused — which reads exactly like a menu that refused the
// keyboard, on a build where it did not.
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el?.textContent?.trim() ?? null;
}),
)
.toBe('Import');
await app.keyboard.press('Escape');
await expect(more).toHaveAttribute('aria-expanded', 'false');
await expect(more).toBeFocused();
});
/**
* New Playlist is a drop target, and declaring it as data must not
* take that away which is why a `PageAction` carries the drop
* handlers rather than the header owning a notion of dropping.
*
* Nothing covered this before, in either tier, and it is the one
* behaviour the migration could plausibly have destroyed silently:
* dragging still *looks* fine against a button that no longer
* accepts anything.
*/
test('New Playlist still accepts a dropped track', async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
const button = app.getByRole('button', {
name: 'New Playlist',
exact: true,
});
await expect(button).toBeVisible();
const result = await app.evaluate(async () => {
const view = document.querySelector(
'[data-testid="main-content"] playlist-view',
)!;
const target = view.shadowRoot!
.querySelector('page-header')!
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
const data = new DataTransfer();
data.setData(
'application/x-yj-tracks',
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
);
const fire = (type: string) =>
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: data,
}),
);
fire('dragover');
await new Promise((r) => setTimeout(r, 50));
// The affordance is the host's state reaching the header's
// button, which is the half a plain handler call would not prove.
const highlighted = target.classList.contains('drag-over');
fire('drop');
await new Promise((r) => setTimeout(r, 200));
return {
highlighted,
opened: view.shadowRoot!.querySelector('.create-form') !== null,
};
});
expect(result).toEqual({ highlighted: true, opened: true });
// Leave the view as it was found.
await app.keyboard.press('Escape');
});
/**
* An action given back when the window widens again. The collapsed
* set is a function of the current width and not of how it got there
* a rule that only ever *added* to it would never widen.
*/
test('gives the buttons back when the window grows', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
await app.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(async () => (await headerFit(app))?.buttons)
.toEqual(ACTIONS);
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { test, expect, waitForEvent } from '../support/fixtures.js';
/**
* The Jobs tab folded into the places the work is started (#27).
*
* The assertion worth making is not that the tab is gone that is one
* line of a table but that **nothing became unreachable when it
* went**. Scanning is the case that mattered: the per-library controls
* lived only on that page, and the tab's own comment says they had been
* moved there out of Settings in the first place.
*
* `#24` wrote down one sentence covering all three size bands: *no
* action is ever unreachable at any supported size*. Deleting a
* destination is exactly the change that can quietly break it.
*/
type Page = import('@playwright/test').Page;
const section = (page: Page, heading: string) =>
page.locator(`config-page config-section[heading="${heading}"]`);
async function openSettings(page: Page, heading: string): Promise<void> {
await page.getByTestId('nav-settings').click();
// The section's own disclosure, by role rather than by `.header`:
// an open Libraries section also contains `job-details-drawer`,
// whose own header matches that class and makes it ambiguous.
const header = section(page, heading)
.getByRole('button', { name: heading })
.first();
await expect(header).toBeVisible();
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click();
}
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
test.describe('background jobs live where the work is started', () => {
test('the Jobs destination is gone', async ({ app }) => {
await expect(app.getByTestId('nav-jobs')).toHaveCount(0);
// And it is not offered as a launch page either, which is the copy
// of the destination list that is easiest to forget.
await openSettings(app, 'General');
const options = await section(app, 'General')
.locator('select')
.first()
.locator('option')
.allTextContents();
expect(options).not.toContain('Jobs');
});
/**
* Scanning is startable from Settings Libraries, and the job that
* results is visible there with its controls. One assertion covers
* both halves, because a Scan All that started nothing would leave
* the panel empty and read exactly like a panel that does not work.
*/
test('a scan is started and watched in Settings', async ({ app }) => {
await openSettings(app, 'Libraries');
const libraries = section(app, 'Libraries');
await libraries.getByRole('button', { name: 'Scan All' }).click();
await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 });
const panel = libraries.locator('job-panel');
await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 });
// The generic affordances are the point of the panel: the tier
// list and the progress rings the other surfaces already had
// cannot open a log.
await expect(
panel.getByRole('button', { name: /^Details/ }),
).toBeVisible();
});
/** A finished job dismisses from where it is shown. */
test('a finished scan can be dismissed in place', async ({ app }) => {
await openSettings(app, 'Libraries');
const panel = section(app, 'Libraries').locator('job-panel');
const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first();
await expect(dismiss).toBeVisible({ timeout: 10_000 });
await dismiss.click();
await expect(panel.locator('job-row')).toHaveCount(0);
});
/**
* Full rescan is destructive and asks first. It is asserted at the
* dialog rather than through it running one against the seeded app
* would delete the library the rest of the suite reads.
*/
test('Full Rescan asks before it does anything', async ({ app }) => {
await openSettings(app, 'Libraries');
await section(app, 'Libraries')
.getByRole('button', { name: 'Full Rescan' })
.click();
const dialog = app.getByRole('dialog', { name: 'Full rescan' });
await expect(dialog).toBeVisible();
// The message is read off the *host*, not the dialog: a wa-dialog
// keeps its slotted content in the host's shadow root, so
// `toContainText` on the dialog itself sees only Web Awesome's
// chrome.
await expect(app.locator('confirm-dialog')).toContainText(
'deletes all library data',
);
await app.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
});
+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);
});
});
+67 -24
View File
@@ -26,7 +26,21 @@ const MIN_VIEWPORT = { width: 800, height: 600 };
const VIEWPORTS = [ const VIEWPORTS = [
{ name: '1440×900', width: 1440, height: 900 }, { name: '1440×900', width: 1440, height: 900 },
{ name: '1024×768', width: 1024, height: 768 }, { name: '1024×768', width: 1024, height: 768 },
// Not the minimum, and that is the point (#24). The sidebar collapses
// to icons *below* 900, so the main panel is 843px at 899 and 700px
// at 900 — the narrowest content area any desktop width produces is
// here, not at the enforced floor. A list that stopped at the minimum
// was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT }, { name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
]; ];
/** /**
@@ -87,9 +101,10 @@ test.describe('the app fits in its own window', () => {
) )
.toBe(true); .toBe(true);
// Settings and Jobs are the two that were unreachable: they are // Settings is the one that was unreachable: it is last in the nav,
// last in the nav, and the pane used to clip rather than scroll. // and the pane used to clip rather than scroll. (Jobs was the other
for (const view of ['jobs', 'settings'] as const) { // half of this until #27 folded it into Settings.)
for (const view of ['explore', 'settings'] as const) {
const item = app.getByTestId(`nav-${view}`); const item = app.getByTestId(`nav-${view}`);
await item.scrollIntoViewIfNeeded(); await item.scrollIntoViewIfNeeded();
@@ -117,27 +132,45 @@ test.describe('the app fits in its own window', () => {
// be dragged here, but a scaled display or a large system font can // be dragged here, but a scaled display or a large system font can
// still land the layout in it, and clipping the nav with no scroll // still land the layout in it, and clipping the nav with no scroll
// is the failure that made Settings unreachable. // is the failure that made Settings unreachable.
const reachable = await app.locator('app-sidebar').evaluate((el) => { //
const settings = el.shadowRoot?.querySelector<HTMLElement>( // The scroll and the measurement share one `evaluate` — #151's
'[data-testid="nav-settings"]', // rule, which this already had — and the whole probe is polled,
); // which it did not: a viewport change settles asynchronously, so a
// single attempt reads whatever the sidebar happened to be doing.
// The probe is safe to repeat because scrolling to the bottom twice
// is scrolling to the bottom.
await expect
.poll(() =>
app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
if (!settings) return null; if (!settings) return null;
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
const item = settings.getBoundingClientRect(); const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect(); const pane = el.getBoundingClientRect();
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top); return (
}); item.bottom <= Math.ceil(pane.bottom) &&
item.top >= Math.floor(pane.top)
expect(reachable).toBe(true); );
}),
)
.toBe(true);
}); });
}); });
test.describe('the title block fits its bar', () => { test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => { test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA // The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px // gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's // inside 64 — pre-existing, and invisible until dropping the h3's
@@ -239,16 +272,26 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => { test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height }); await app.setViewportSize({ width: vp.width, height: vp.height });
const excess = await app.evaluate(() => { // Polled, for the reason the track-row test above is: since #143
const de = document.documentElement; // the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth; return de.scrollWidth - de.clientWidth;
}); }),
)
// The other half: at every size this app promises, the fix costs .toBe(0);
// nothing. A scrollbar that is always there is a worse answer
// than the clipping it replaced.
expect(excess).toBe(0);
}); });
} }
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
const VIEWS = [ const VIEWS = [
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists', 'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
'explore', 'downloads', 'jobs', 'settings', 'explore', 'downloads', 'autotag', 'settings',
]; ];
type IconState = { name: string; hasSvg: boolean }; type IconState = { name: string; hasSvg: boolean };
+6 -4
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect, navigateTo } from '../support/fixtures.js';
/** /**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page * H-19: Playlists, Downloads, Jobs, Settings and Home had a page
@@ -21,7 +21,6 @@ const VIEWS: [string, string, boolean][] = [
['tracks', 'Tracks', true], ['tracks', 'Tracks', true],
['explore', 'Explore', false], ['explore', 'Explore', false],
['downloads', 'Downloads', false], ['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
]; ];
/** The header lives in the view's shadow root, inside its own. */ /** The header lives in the view's shadow root, inside its own. */
@@ -53,13 +52,16 @@ const TAGS: Record<string, string> = {
tracks: 'track-list', tracks: 'track-list',
explore: 'explore-view', explore: 'explore-view',
downloads: 'downloads-view', downloads: 'downloads-view',
jobs: 'jobs-view',
}; };
test.describe('every primary view says what it is', () => { test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => { test('each one has the shared header, with a heading', async ({ app }) => {
// By event rather than by nav item: a destination is not
// guaranteed to have one any more (#25 — Downloads is absent
// without a download client), and every one of these is still a
// primary view with a header, which is what this spec is about.
for (const [view, heading, hasCount] of VIEWS) { for (const [view, heading, hasCount] of VIEWS) {
await app.getByTestId(`nav-${view}`).click(); await navigateTo(app, view);
await expect(app.getByTestId('main-content')).toHaveAttribute( await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view', 'data-active-view',
view, view,
+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();
});
});
+112 -4
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect, LONG_TRACK } from '../support/fixtures.js';
/** /**
* The phone shell (plan 016 B2, phase 1). * The phone shell (plan 016 B2, phase 1).
@@ -130,6 +130,25 @@ test.describe('the shell on a phone', () => {
// are here, and they are the *same* components -- this view // are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it. // composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible(); 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(); await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack. // Back goes where the user came from, through the nav stack.
@@ -138,6 +157,78 @@ test.describe('the shell on a phone', () => {
.toHaveAttribute('data-active-view', 'tracks'); .toHaveAttribute('data-active-view', 'tracks');
}); });
/**
* The same journey with a track that has **no cover art** (#150).
*
* The test above starts the *first* row of the track list, so which
* track it plays is the order the scan inserted them in and the
* answer decided whether it passed. A track with artwork renders an
* `<img>`, which is no obstacle; one without renders a placeholder
* `wa-icon`, which took every click aimed at the button beneath it,
* because that button is absolutely positioned with `z-index: auto`
* and the art is a *later* sibling. They tied, and the later one won.
*
* So this picks a track *for* the property that broke it, which is
* the only way the assertion means anything: the version above passes
* on a broken build roughly two runs in three, which is exactly how
* it came to cost three CI cycles across two branches that could not
* have caused it.
*/
test('opens the full-screen now playing for a track with no art', async ({
app,
}) => {
// `LONG_TRACK` by name, and not "the first track with no
// CoverArt": the *library* model reports that field empty for
// every row in this fixture (31 of 31), so filtering on it selects
// nothing in particular and picked a 2-second track, which had
// finished before the assertions ran. The placeholder check below
// is what actually holds the property this test needs.
const started = await app.evaluate(async (longTitle) => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; TrackName: string }[];
const bare = tracks.find((t) => t.TrackName === longTitle);
if (!bare) return null;
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[[bare.FilePath], 0, false, { type: '', id: 0, label: '' }],
10_000,
);
await window.__yjEvents.call('queue.Queue.Play', [], 5_000);
return bare.TrackName;
}, LONG_TRACK);
expect(started).toBe(LONG_TRACK);
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
// **The placeholder is the whole point**, so it is asserted rather
// than assumed: this test is about the thing that renders when
// there is no artwork. If the fixture ever gives this album a
// cover, this fails and says so instead of passing while measuring
// the easy case.
//
// One selector rather than a chain from the host: Playwright's CSS
// engine pierces an open shadow root, and chaining from the host
// element does not reach into it.
await expect(
app.locator('now-playing .cover-placeholder'),
).toBeAttached();
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content'))
.toHaveAttribute('data-active-view', 'now-playing');
await app.getByTestId('npv-back').click();
});
test('offers no way in on a desktop, where the bar is whole', async ({ app }) => { test('offers no way in on a desktop, where the bar is whole', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 }); await app.setViewportSize({ width: 1440, height: 900 });
@@ -154,9 +245,26 @@ test.describe('the shell on a phone', () => {
await expect(app.locator('now-playing')).toBeVisible(); await expect(app.locator('now-playing')).toBeVisible();
// Volume is the hardware keys' job on a phone, and a 4px seek bar // Volume is the hardware keys' job on a phone, and a 4px seek bar
// is not a thumb target -- both belong to a later phase's // is not a thumb target -- both belong to the full-screen
// full-screen now-playing view. // now-playing view.
await expect(app.locator('audio-player volume-control')).toBeHidden(); //
// `.bottom-bar volume-control`, not `audio-player volume-control`:
// #42 moved the control out of that component and into the bar, and
// **the old locator would have kept passing** — `toBeHidden()` is
// satisfied by an element that does not exist, so this assertion
// would have gone on reporting success about nothing. Its partner
// below is what makes this one mean something.
await expect(app.locator('.bottom-bar volume-control')).toBeHidden();
// The element is there and hidden, rather than absent: the check
// above cannot tell those apart on its own.
await expect(app.locator('.bottom-bar volume-control')).toHaveCount(1);
// And the seek bar is still inside the transport, where it stands
// down by its own media query.
await expect(
app.locator('audio-player').locator('seek-bar'),
).toBeHidden();
}); });
}); });
+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);
});
});
+346
View File
@@ -0,0 +1,346 @@
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 the scrim has no uncovered
* pixels, 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);
});
});
/**
* **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);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #24 the queue panel does not take the page's width away from it.
*
* The panel is `flex-shrink: 0` in the flow of `.content-area`, so an
* open queue used to be paid for by the main panel. Measured on
* Playlists before the fix:
*
* | viewport | main panel |
* |---|---|
* | 900×600 | 379px all three header actions clipped |
* | 390×780 | 69px |
* | 320×600 | **0px** |
*
* **900×600 is the worst desktop case, not the 800×600 minimum**, and
* that is the trap this file exists to keep closed: the sidebar
* collapses to icons *below* 900, so the main panel is 843px at 899 and
* 700px at 900. A spec that checks "the minimum" and stops has not
* checked the worst case which is what every viewport list in this
* suite did before this.
*
* These assert the *content's* width rather than the panel's mode
* wherever they can, because the mode is the mechanism and the width is
* the complaint.
*/
/** The bands from plan 018's size matrix, plus the pixel above the collapse. */
const BANDS = [
{ name: 'a wide desktop (1280×800)', width: 1280, height: 800, inline: true },
{ name: 'the default window (1100×720)', width: 1100, height: 720, inline: true },
{ name: 'a laptop (1024×768)', width: 1024, height: 768, inline: true },
{ name: 'the worst desktop width (900×600)', width: 900, height: 600, inline: false },
{ name: 'the enforced minimum (800×600)', width: 800, height: 600, inline: false },
{ name: 'a phone (390×780)', width: 390, height: 780, inline: false },
{ name: '400% zoom (320×600)', width: 320, height: 600, inline: false },
];
/**
* How much room the content has, and whether the shell needs scrolling
* to reach any of itself.
*/
const shellGeometry = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const main = document.querySelector('#main-content')!.getBoundingClientRect();
const panel = document.querySelector('#queue-panel')!;
return {
mainWidth: Math.round(main.width),
overlay: panel.hasAttribute('overlay'),
open: panel.hasAttribute('open'),
bodyScrollWidth: document.body.scrollWidth,
bodyClientWidth: document.body.clientWidth,
};
});
/**
* 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) {
test(`at ${band.name}`, async ({ app }) => {
await app.setViewportSize({ width: band.width, height: band.height });
await openQueue(app);
// The mode is settled by a ResizeObserver, so poll rather than
// read once: a single read races the resize and reports the
// previous viewport's answer.
await expect
.poll(async () => (await shellGeometry(app)).overlay)
.toBe(!band.inline);
const geo = await shellGeometry(app);
// The floor is the point of the whole issue. Inline, the queue is
// affordable and the content keeps the rest; as an overlay the
// content keeps *everything*, which is what makes 0px at 320
// impossible rather than merely unlikely.
expect(geo.mainWidth).toBeGreaterThanOrEqual(320);
if (!band.inline) {
expect(geo.mainWidth).toBeGreaterThanOrEqual(
Math.min(band.width, 320),
);
}
// And opening the queue must not make the shell overflow.
expect(geo.bodyScrollWidth).toBeLessThanOrEqual(geo.bodyClientWidth);
});
}
});
test.describe('an overlaid queue says it is over the content', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
});
test('draws a scrim and closes when it is clicked', async ({ app }) => {
await openQueue(app);
const panel = app.locator('#queue-panel');
await expect(panel).toHaveAttribute('overlay', '');
// The scrim is `aria-hidden` on purpose — it is a dismissal target,
// and the named routes out are the close button and Escape — so it
// is located structurally rather than by role.
await panel.evaluate((el) =>
el.shadowRoot!.querySelector<HTMLElement>('.scrim')!.click(),
);
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
/**
* `getByRole`, not a shadow-root query: this repo has shipped a
* nameless control three times, and a drawer with a scrim is exactly
* the shape that grows a fourth.
*/
test('offers a named close button', async ({ app }) => {
await openQueue(app);
const close = app.getByRole('button', { name: 'Close queue' });
await expect(close).toBeVisible();
await close.click();
await expect(app.locator('#queue-button')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('closes on Escape and gives focus back to the toggle', async ({
app,
}) => {
const toggle = app.locator('#queue-button');
await toggle.focus();
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await app.keyboard.press('Escape');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(toggle).toBeFocused();
});
});
/**
* The inline panel is the mode that already worked, and the one every
* other queue spec is written against. It keeps its resize handle and
* gains none of the overlay's chrome.
*/
test.describe('a wide window keeps the queue beside the content', () => {
test('no scrim, no close button, and the content is narrower', async ({
app,
}) => {
await app.setViewportSize({ width: 1280, height: 800 });
const widthWithoutQueue = (await shellGeometry(app)).mainWidth;
await openQueue(app);
await expect(app.locator('#queue-panel')).not.toHaveAttribute(
'overlay',
'',
);
const geo = await shellGeometry(app);
expect(geo.mainWidth).toBeLessThan(widthWithoutQueue);
await expect(
app.getByRole('button', { name: 'Close queue' }),
).toHaveCount(0);
});
});
+299
View File
@@ -0,0 +1,299 @@
import {
test,
expect,
callBinding,
navigateTo,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The queue panel's mouse model (#43): single click selects, ctrl and
* shift extend, double click plays from that row.
*
* **All four already worked, and nothing pinned any of them** which is
* the whole reason the report could be made and could not be settled.
* `queue-reorder.spec.ts` covers the keyboard, `queue-overlay.spec.ts`
* covers the panel's mode, and the component tier has the reorder
* arithmetic; the pointer path had no coverage in either tier, so
* "selection is broken here" and "selection is fine here" were equally
* consistent with a green suite.
*
* Two things this spec is deliberately shaped around.
*
* **The clicks are real.** A `dispatchEvent(new MouseEvent('click'))`
* on a row exercises the delegated handler and *not* the question being
* asked, which is what the pointer lands on: the rows carry
* `explore-link` names that take their own clicks, and a synthetic
* event aimed at the row reports a selection the mouse would never have
* produced. Every click here goes through Playwright.
*
* **The playing assertions use the 90-second fixture.** Every other
* track is 26 seconds, so "double click plays row 3" read against a
* 2-second track reports whatever auto-advance moved on to measured
* during this work as row 3 double-clicked and row 4 playing, which
* reads exactly like an off-by-one in `PlayIndex` and is not one.
*/
/**
* How long a click may take to show up as a highlight.
*
* **A poll with the default 5s timeout cannot see this defect**, and
* that is the point of naming it. `queue-panel` repaints its rows two
* ways `onSelectionChanged()` calls `virtualizer.requestUpdate()`,
* and `.keyFunction` is a per-render arrow, which is itself a changed
* property the virtualizer reacts to. With **both** removed the
* highlight still arrives, on whatever unrelated render happens next:
* measured at 134ms, 3,866ms and 5,816ms for three clicks, against
* 5ms, 16ms and 17ms on a healthy build.
*
* A user cannot tell "four seconds late" from "broken", which is very
* close to what this issue reports. So the assertion is that the
* highlight is *prompt*, with a bound ~30x the measured healthy case
* and an order of magnitude under the degraded one.
*/
const HIGHLIGHT_MS = 500;
/** The queue's own answer, never the DOM's. */
async function playing(app: Page): Promise<{ index: number; title: string }> {
const state = await callBinding<{
currentIndex: number;
tracks: { title: string }[];
}>(app, 'queue.Queue.GetState');
return {
index: state.currentIndex,
title: state.tracks[state.currentIndex]?.title ?? '',
};
}
/** Which rows are selected, as the accessibility tree sees it. */
const selected = (app: Page) =>
app.evaluate(() =>
[
...document
.querySelector('queue-panel')!
.shadowRoot!.querySelectorAll('[data-index]'),
]
.filter((row) => row.getAttribute('aria-selected') === 'true')
.map((row) => Number((row as HTMLElement).dataset['index'])),
);
/**
* Six tracks with the long one in the middle, so a "play from here"
* assertion has something to land on that will still be playing when it
* is read back.
*/
async function queueSixAndOpen(app: Page): Promise<void> {
const paths = await app.evaluate(async (longTitle) => {
// `TrackName`, not `Title`: the library model names it after the
// tag, and the *queue* is what calls it `title`.
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string; TrackName: string; Album: string }[];
const long = tracks.find((t) => t.TrackName === longTitle);
/**
* **Tracks that have an album**, which is a requirement of one of
* the tests and was previously left to luck (#156).
*
* `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.
*
* Asking for what the test needs is the fix. It is not a
* narrowing: every assertion here wants an ordinary track, and
* "the first five rows" was never a way to ask for one in a
* library whose whole purpose is edge cases.
*/
const rest = tracks
.filter((t) => t.TrackName !== longTitle && t.Album !== '')
.slice(0, 5);
// Index 3 is the long one: far enough down that a shift-extend has
// room either side of it.
return [
...rest.slice(0, 3).map((t) => t.FilePath),
long!.FilePath,
...rest.slice(3).map((t) => t.FilePath),
];
}, LONG_TRACK);
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
// A closed panel renders no list at all.
await app.locator('#queue-button').click();
await expect(app.locator('queue-panel .track-item').first()).toBeVisible();
await expect(app.locator('queue-panel .track-item')).toHaveCount(6);
}
/** The row at a data-index, not the nth child: see the note in the file. */
const row = (app: Page, index: number) =>
app.locator(`queue-panel .track-item[data-index="${index}"]`);
test.describe('selecting in the queue with a mouse', () => {
// The suite shares one backend in file order, and a queue and an open
// panel both outlive the page. `queue-reorder.spec.ts` sets the
// precedent and the reason: a spec that spends state fails the next
// one, in a list that reads like a regression in whatever you hold.
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* an empty queue is the state we were asking for */
});
const open = await app.locator('queue-panel[open]').count();
if (open > 0) await app.locator('#queue-button').click();
});
test('a single click selects that row and only that row', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
// And it *replaces* rather than accumulating, which is the half a
// test of one click cannot see.
await row(app, 4).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([4]);
});
test('ctrl adds a row and shift extends a range', async ({ app }) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await row(app, 3).click({ modifiers: ['Control'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3]);
// From the last row touched, so 3→5, keeping the ctrl-picked 1.
await row(app, 5).click({ modifiers: ['Shift'] });
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1, 3, 4, 5]);
// A plain click collapses the whole thing back to one.
await row(app, 2).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([2]);
});
test('a double click plays from that row', async ({ app }) => {
await queueSixAndOpen(app);
// Row 3 is the 90-second track. Asked of the backend, because the
// panel's own highlight is a different claim.
await row(app, 3).dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
// Playing is not selecting: the double click clears the selection
// it made on the way through, or every play leaves a row looking
// picked out for an action the user did not ask for.
await expect.poll(() => selected(app)).toEqual([]);
});
/**
* The one collision the report is actually about.
*
* Every track, album and artist name in the app navigates
* (`utils/explore-link.ts`), and it does that by **stopping the
* click's propagation** in its own words, "the row must not also
* treat it as a selection". So a click that lands on the name text
* navigates and selects nothing, in the queue panel and in the track
* list alike.
*
* That is deliberate and it is pinned here rather than argued with,
* because the measurement says the queue is not the surface where it
* hurts: a horizontal hit-scan of a row at three heights makes the
* queue row **12%** link and the track list's row **21%** the panel
* the report calls broken is *less* covered by links than the list it
* calls correct. What is left is one deliberate exception, and a
* change to it should have to fail a test.
*/
test('a click on a name navigates instead, and that is the exception', async ({
app,
}) => {
await queueSixAndOpen(app);
await row(app, 1).click();
await expect
.poll(() => selected(app), { timeout: HIGHLIGHT_MS })
.toEqual([1]);
// `.track-title .explore-link`, not `.explore-link` first(): a row
// has two, and which one `first()` finds depends on whether the
// *title* is a link at all. It is not, for a track with no album —
// `explore-link` renders plain text where it cannot route — so the
// loose locator silently clicked the **artist** instead and the
// assertion below was about a different destination than the one
// being exercised (#156).
await row(app, 2).locator('.track-title .explore-link').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
// Row 2 did not join the selection — the link took the click.
await expect.poll(() => selected(app)).toEqual([1]);
await navigateTo(app, 'tracks');
});
/**
* And the other half of that bargain: the link holds its navigation
* for one double-click interval and drops it if a second click
* arrives, so double-clicking a *name* still plays the row rather
* than navigating away from it. That is what makes the exception
* above survivable, and it is the part most likely to break silently
* if the grace interval is ever removed.
*/
test('a double click on a name plays rather than navigating', async ({
app,
}) => {
await queueSixAndOpen(app);
// Read rather than assumed: which view the app lands on is the
// user's `DefaultPage`, so naming one here would be asserting on a
// config value in a test about a double click.
const before = await app
.getByTestId('main-content')
.getAttribute('data-active-view');
await row(app, 3).locator('.explore-link').first().dblclick();
await expect.poll(() => playing(app)).toEqual({
index: 3,
title: LONG_TRACK,
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
before!,
);
});
});
+11 -6
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js'; import { test, expect, navigateTo } from '../support/fixtures.js';
/** /**
* Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app. * Plan 007 phase 5: a11y.1 and a11y.2, frozen against the real app.
@@ -18,16 +18,19 @@ test.describe('Settings is reachable without a mouse', () => {
}) => { }) => {
await app.getByTestId('nav-settings').click(); await app.getByTestId('nav-settings').click();
const headers = app.locator('config-page config-section .header'); // Per *section*, not per `.header`: a section holding a
// `job-panel` (#27) also contains `job-details-drawer`, whose own
// header carries that class and is not a disclosure.
const sections = app.locator('config-page config-section');
await expect(headers.first()).toBeVisible(); await expect(sections.first()).toBeVisible();
const count = await headers.count(); const count = await sections.count();
expect(count).toBeGreaterThan(4); expect(count).toBeGreaterThan(4);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const header = headers.nth(i); const header = sections.nth(i).locator('.header').first();
expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON'); expect(await header.evaluate((el) => el.tagName)).toBe('BUTTON');
expect(['true', 'false']).toContain( expect(['true', 'false']).toContain(
@@ -56,7 +59,9 @@ test.describe('Settings is reachable without a mouse', () => {
test.describe("Downloads' tabs are tabs", () => { test.describe("Downloads' tabs are tabs", () => {
test('arrow keys move the selection and swap the panel', async ({ app }) => { test('arrow keys move the selection and swap the panel', async ({ app }) => {
await app.getByTestId('nav-downloads').click(); // By event, not by nav item: with no download client configured
// there is no Downloads destination to click (#25).
await navigateTo(app, 'downloads');
const view = app.locator('downloads-view'); const view = app.locator('downloads-view');
const requests = view.getByRole('tab', { name: 'Requests' }); const requests = view.getByRole('tab', { name: 'Requests' });
+269
View File
@@ -0,0 +1,269 @@
import { test, expect } from '../support/fixtures.js';
/**
* The top bar fits the window it is in (#143).
*
* **This is measured per child, not on the shell**, which is #69's
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
* the *document* needs no sideways scrolling, and clipping inside a
* component is invisible to it which is exactly why that spec was
* green throughout this defect. What a user sees is a control rendered
* past the edge of the bar it belongs to, so that is what is asserted.
*
* **And it is measured with a job running**, which is the half the
* original report missed. `job-indicator` is `hidden` while idle and up
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
* still and 862px during a scan 171 to 262px of overflow, arriving
* exactly when a user has reason to look at that bar. Nothing else in
* this suite has ever measured a layout with work in flight;
* `/__test/emit` stages it without staging the scan.
*/
type Page = import('@playwright/test').Page;
/**
* The widths this asks about.
*
* 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 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 = [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
* at 12rem by the component, so this is the widest the indicator can
* be measuring with "Scanning" instead reports a bar that fits and a
* defect that is 100px smaller than it is.
*/
const LONG_JOB = {
id: 'top-bar-fit',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music from the external drive',
current: 40,
total: 100,
};
/**
* Every child's right edge against the bar's own content box.
*
* The content box, not `clientWidth`: the bar has a 2em right gutter,
* and a control sitting in the padding is already the failure it is
* simply one that `scrollWidth` under-reports, because `scrollWidth`
* counts the left padding and not the right.
*/
const overflowingChildren = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
const left = box.left + parseFloat(style.paddingLeft);
const right = box.right - parseFloat(style.paddingRight);
return [...bar.children]
.filter((child) => {
const cs = getComputedStyle(child);
// Out of flow is out of the question: a collapsed wordmark is
// `position: absolute` and 1px wide precisely so it costs the
// row nothing.
if (cs.display === 'none' || cs.position === 'absolute') return false;
const r = child.getBoundingClientRect();
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
})
.map((child) => {
const r = child.getBoundingClientRect();
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
});
});
/** What the fit pass gave up, read back off the DOM it changed. */
const collapsed = (page: Page) =>
page.evaluate(() => ({
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
jobLabel: !!document.querySelector('job-indicator[compact]'),
}));
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,
}) => {
await app.setViewportSize({ width, height: 600 });
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// The indicator has to actually be up, or this test passes by
// measuring the idle case under another name.
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
}
/**
* The other half of "measured, never breakpointed": a rule that
* collapses defensively at every narrow width fits just as well and
* is a worse app. 1440 is roomy at any job title; 899 was measured to
* fit with the longest one, because `nav-history` is not there yet.
*/
test('nothing is given up where there is room for it', async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await testctl.emit('JobsChanged', [LONG_JOB]);
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* And it gives them back. The pass starts from all-visible every
* time, so this is the property that a rule which only ever *added*
* to the collapsed set would fail the wordmark would be gone for
* the rest of the session after one narrow moment.
*/
test('the wordmark comes back when the window does', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: true,
jobLabel: true,
});
await app.setViewportSize({ width: 1440, height: 900 });
await expect.poll(() => collapsed(app)).toEqual({
wordmark: false,
jobLabel: false,
});
});
/**
* The wordmark yields its width and not its existence: `display:
* none` would take the document from one top-level heading to none.
*/
test('the collapsed wordmark is still the document heading', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And the indicator keeps saying what it is doing after its visible
* label goes the `sr-only` live region is what announces the state,
* which is the same argument the phone's own rule was written on.
*/
test('the job indicator still announces its state without its label', async ({
app,
testctl,
}) => {
await testctl.emit('JobsChanged', [LONG_JOB]);
await app.setViewportSize({ width: 600, height: 600 });
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
const spoken = await app
.locator('job-indicator')
.evaluate(
(el) =>
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
);
expect(spoken).toContain('Scanning Music from the external drive');
// Leave the app as the next spec expects to find it.
await app.setViewportSize({ width: 1440, height: 900 });
});
});
+7 -2
View File
@@ -4,6 +4,7 @@ import {
eventNames, eventNames,
resetEvents, resetEvents,
waitForEvent, waitForEvent,
navigateTo,
} from '../support/fixtures.js'; } from '../support/fixtures.js';
/** /**
@@ -39,7 +40,9 @@ test.describe('view lifecycle', () => {
test('a keypress on Settings does not reach the Autotag queue', async ({ test('a keypress on Settings does not reach the Autotag queue', async ({
app, app,
}) => { }) => {
await app.getByTestId('nav-autotag').click(); // By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await expect(app.getByTestId('main-content')).toHaveAttribute( await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view', 'data-active-view',
'autotag', 'autotag',
@@ -83,7 +86,9 @@ test.describe('view lifecycle', () => {
// The other half of the same bug (H-2): two document keydown handlers // The other half of the same bug (H-2): two document keydown handlers
// with no arbitration meant `s` on this page skipped the album *and* // with no arbitration meant `s` on this page skipped the album *and*
// toggled shuffle. As a panel binding it can only mean one thing. // toggled shuffle. As a panel binding it can only mean one thing.
await app.getByTestId('nav-autotag').click(); // By event, not by nav item: Autotag is hidden by default (#25)
// and a hidden view is still reachable.
await navigateTo(app, 'autotag');
await expect await expect
.poll(() => pendingCount(app)) .poll(() => pendingCount(app))
.toMatch(/^Pending \(\d+\)$/); .toMatch(/^Pending \(\d+\)$/);
+116
View File
@@ -0,0 +1,116 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
/**
* Which destinations the navigation offers (#25).
*
* Eleven sidebar entries is more than most libraries need, so they are
* individually toggleable from Settings, Autotag is off until asked for
* and Downloads is absent until there is a client to download with.
*
* **The assertions are about the navigation, not about the setting.**
* "The config was saved" is the plumbing, and the two most recent bugs
* in this area #69 and #72 both shipped green under specs that
* measured exactly that. What a person sees is whether the item is in
* the accessibility tree, and whether the view is still reachable when
* it is not.
*
* This runs against the seeded app, whose config is defaults and whose
* download client list is empty, so the initial state below is what a
* fresh install looks like.
*/
type Page = import('@playwright/test').Page;
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/** The Navigation section's checkbox for a destination. */
const viewToggle = (page: Page, label: string) =>
page.getByRole('checkbox', { name: `Show ${label} in the navigation` });
async function openNavigationSettings(page: Page): Promise<void> {
await page.getByTestId('nav-settings').click();
const section = page.locator(
'config-page config-section[heading="Navigation"] .header',
);
await expect(section).toBeVisible();
if ((await section.getAttribute('aria-expanded')) === 'false') {
await section.click();
}
await expect(section).toHaveAttribute('aria-expanded', 'true');
}
test.describe('configurable destinations', () => {
test('Autotag is off by default and Downloads needs a client', async ({
app,
}) => {
await expect(app.getByTestId('nav-home')).toBeVisible();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
await expect(app.getByTestId('nav-downloads')).toHaveCount(0);
});
/**
* Hiding takes the item away and nothing else. Detail views navigate
* into these and the launch page is one of them, so a destination
* with no nav item still has to open.
*/
test('a hidden destination is still reachable', async ({ app }) => {
await navigateTo(app, 'autotag');
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// And nothing is falsely lit while standing on it -- the same rule
// a detail view follows, with no special case for either.
await expect(navItem(app, 'Home')).toHaveAttribute('aria-current', 'false');
});
test('switching Autotag on adds it to the sidebar', async ({ app }) => {
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').check();
await expect(app.getByTestId('nav-autotag')).toBeVisible();
// Clicking it is the point of having it.
await app.getByTestId('nav-autotag').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'autotag',
);
// Put it back, or the next spec against this app sees a library
// this one changed.
await openNavigationSettings(app);
await viewToggle(app, 'Autotag').uncheck();
await expect(app.getByTestId('nav-autotag')).toHaveCount(0);
});
/**
* Settings has no toggle at all, rather than a toggle that refuses:
* a user who hides it cannot get back to unhide it. The backend
* refuses it too, because `config.toml` is hand-editable.
*/
test('Settings cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Settings')).toBeDisabled();
await expect(app.getByTestId('nav-settings')).toBeVisible();
});
/**
* The launch page is refused while it is the launch page, which is a
* state the user can leave by changing the launch page above it.
*/
test('the launch page cannot be switched off', async ({ app }) => {
await openNavigationSettings(app);
await expect(viewToggle(app, 'Home')).toBeDisabled();
});
});
+73
View File
@@ -111,6 +111,79 @@ export async function bindingCalls(page: Page): Promise<string[]> {
return calls.map(nameOf); return calls.map(nameOf);
} }
/**
* Go to a view without going through the navigation.
*
* `navigate` is the event the shell listens for and every nav item, card
* and detail view dispatches, so this is the app's own mechanism rather
* than a test-only door. It exists because a destination is not
* guaranteed to have a nav item any more (#25): Autotag is hidden until
* the user asks for it and Downloads until a client exists, and a spec
* about what a *view* does should not also be asserting that the
* sidebar offers it.
*/
export async function navigateTo(page: Page, view: string): Promise<void> {
await page.evaluate(
(v) =>
void document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: v },
bubbles: true,
composed: true,
}),
),
view,
);
await page
.getByTestId('main-content')
.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). */ /** Thin client for the dev-only /__test/ surface (backend/testctl). */
export class TestCtl { export class TestCtl {
constructor(private readonly baseURL: string) {} constructor(private readonly baseURL: string) {}
@@ -7,6 +7,7 @@ export {
}; };
export type { export type {
AlbumMatchView,
AlignmentView, AlignmentView,
ApplyResultView, ApplyResultView,
CandidateView, CandidateView,
@@ -1,6 +1,61 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
/**
* AlbumMatchView is "the autotagger already has a confident match for
* the album you are looking at".
*
* It is deliberately not a score. The album page renders a suggestion,
* and a suggestion has to be actionable: which release, what it is
* called, and whether acting on it here would do the whole album or
* only part of it.
*/
export interface AlbumMatchView {
/**
* GroupKey is the tagging group the actions operate on.
*/
"groupKey": string;
/**
* Recommendation is the tier, as a string, for a caller that
* wants to render the strength rather than trust the filter.
*/
"recommendation": string;
/**
* Score is the top candidate's raw score, 0..1.
*/
"score": number;
/**
* ReleaseMBID is the release Apply would write.
*/
"releaseMbid": string;
/**
* Title and ArtistCredit name that release, so the banner can say
* what it is offering rather than "a match".
*/
"title": string;
"artistCredit": string;
/**
* TrackCount is the group's local track count.
*/
"trackCount": number;
/**
* GroupCount is how many tagging groups this album spans.
*
* More than one means a multi-disc album (one group per disc), and
* it is the reason this is a field rather than an implementation
* detail: applying "the album" from a single button would retag
* one disc of three and leave the folder holding a mix of old and
* new tags. The caller offers review instead.
*/
"groupCount": number;
}
/** /**
* AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1 * AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
* means "candidate has this track, folder doesn't" (status=missing). * means "candidate has this track, folder doesn't" (status=missing).
@@ -160,6 +160,39 @@ export function ListPendingFolders(libraryID: number): $CancellablePromise<$mode
return $Call.ByID(617511590, libraryID); return $Call.ByID(617511590, libraryID);
} }
/**
* MatchForAlbum answers "does the autotagger have something confident
* to say about this album", for the album detail page.
*
* Three things about it are load-bearing.
*
* **It costs no MusicBrainz request.** Everything it needs is already
* on disk: `tagging_items` carries the top score and release from the
* background prefetch, and `tagging_candidates` durably holds the
* scored list. The rate limiters here are shared with every page the
* user can open, so a lookup that fires on page load must not join
* that queue which also means this returns nothing for a folder
* nobody has scored yet, rather than scoring it now. That is the
* right trade: the prefetch will get to it, and a page that silently
* spends a minute of somebody's MusicBrainz budget to draw a banner
* is worse than a page that says nothing.
*
* **The tier is computed, not read.** `tagging_items.score` is the raw
* number and `Recommend` is what turns it into a claim capping it
* for an ambiguous runner-up, an incomplete alignment or a folder too
* small to corroborate itself. Filtering on the raw score would
* promise confidence the scorer had explicitly withheld.
*
* **Nothing is said about an album the user has already answered
* for.** Only a `pending` group qualifies: `confirmed` covers both a
* finished apply and an explicit "leave as is", and `skipped` is the
* user saying not now. Re-offering either is nagging, and "leave as
* is" would be actively wrong to argue with.
*/
export function MatchForAlbum(albumID: number): $CancellablePromise<$models.AlbumMatchView | null> {
return $Call.ByID(514173221, albumID);
}
/** /**
* RetagGroup flips a group back to 'pending' so the user can * RetagGroup flips a group back to 'pending' so the user can
* re-review after an apply or skip. Drops the durably-cached * re-review after an apply or skip. Drops the durably-cached
@@ -69,6 +69,14 @@ export function GetPinDefaultPlaylist(): $CancellablePromise<boolean> {
return $Call.ByID(3818283301); return $Call.ByID(3818283301);
} }
/**
* GetPopupVolume reports whether the bottom bar's volume control is a
* click-to-open popup rather than an inline slider (#42).
*/
export function GetPopupVolume(): $CancellablePromise<boolean> {
return $Call.ByID(2885777);
}
/** /**
* GetQueueFallback returns what plays, if anything, once the queue * GetQueueFallback returns what plays, if anything, once the queue
* runs out. * runs out.
@@ -112,6 +120,16 @@ export function GetTrackListColumns(): $CancellablePromise<tracklist$0.Column[]
return $Call.ByID(3426289065); return $Call.ByID(3426289065);
} }
/**
* GetViewVisibility reports which primary views the sidebar should
* show, answered for every known view rather than only the ones the
* config mentions -- so the frontend filters on a value and never has
* to hold a second copy of the defaults.
*/
export function GetViewVisibility(): $CancellablePromise<{ [_ in string]?: boolean } | null> {
return $Call.ByID(2798108026);
}
/** /**
* Load reads and parses the config file from disk. * Load reads and parses the config file from disk.
*/ */
@@ -197,6 +215,18 @@ export function SetPinDefaultPlaylist(pin: boolean): $CancellablePromise<void> {
return $Call.ByID(372446849, pin); return $Call.ByID(372446849, pin);
} }
/**
* SetPopupVolume saves the volume control's presentation.
*
* Nothing to validate: both values are legal at every width, and the
* frontend additionally stands the inline slider down below the phone
* breakpoint whatever this says, because that is about room rather than
* about preference.
*/
export function SetPopupVolume(popup: boolean): $CancellablePromise<void> {
return $Call.ByID(1430308453, popup);
}
/** /**
* SetQueueFallback validates and saves a new queue-fallback mode. * SetQueueFallback validates and saves a new queue-fallback mode.
*/ */
@@ -247,6 +277,20 @@ export function SetTrackListColumns(columns: tracklist$0.Column[] | null): $Canc
return $Call.ByID(4226159685, columns); return $Call.ByID(4226159685, columns);
} }
/**
* SetViewVisible shows or hides one primary view.
*
* Two refusals, both about a state the user cannot get out of from the
* UI they would be left with: Settings is never hideable, and the
* launch page is never hideable while it is the launch page (change it
* first). Hiding a view does not make it unreachable -- `navigate`
* still resolves it, which detail views depend on -- it only takes the
* nav item away.
*/
export function SetViewVisible(view: string, visible: boolean): $CancellablePromise<void> {
return $Call.ByID(1751982648, view, visible);
}
/** /**
* Validate returns errors if there is a breaking issue with the config. * Validate returns errors if there is a breaking issue with the config.
*/ */
@@ -431,8 +431,17 @@ export interface TopResult {
/** /**
* Library status populated from index cross-reference columns. * Library status populated from index cross-reference columns.
*
* LocalID is the one the cards read. It is the local row behind
* this entity an album, a file, an artist and it is set and
* cleared by a test against `audio_files`, so it means "there is
* something of mine here". InLibrary is written by the same pass
* but is a one-way ratchet the prune can only clear alongside a
* local id, so it is the weaker of the two and stays for scoring
* (`fwInLibrary`), which is where an approximate answer is fine.
*/ */
"inLibrary": boolean; "inLibrary": boolean;
"localId"?: number;
} }
/** /**
@@ -98,6 +98,26 @@ export function GetAlbumsByArtist(artist: string, libraryID: number): $Cancellab
return $Call.ByID(1456840721, artist, libraryID); return $Call.ByID(1456840721, artist, libraryID);
} }
/**
* GetAlbumsCompleteness answers the same question for a screenful of
* albums in one query, keyed by album id.
*
* A card grid asks this about every card that has a local album behind
* it, and one query per card is how a grid of fifty albums becomes
* fifty round trips. The answer matters there for the reason it
* matters on the album page: an album held 9 tracks of 12 has to show
* the count, and a bare tick saying "in your library" is the complaint
* this whole rule came from.
*
* An album with no row in the result is one with no files, and it is
* absent rather than zeroed "I have none of this" and "I have no
* idea" are the same third state `Known` exists to keep apart, and a
* caller reading a missing key gets nothing rather than a confident 0.
*/
export function GetAlbumsCompleteness(albumIDs: number[] | null): $CancellablePromise<{ [_ in `${number}`]?: $models.AlbumCompleteness } | null> {
return $Call.ByID(531636827, albumIDs);
}
/** /**
* GetAllLibrariesWithTrackCounts lists the libraries and their sizes. * GetAllLibrariesWithTrackCounts lists the libraries and their sizes.
*/ */
@@ -142,6 +142,18 @@ export function SetVolume(desiredVolume: $models.UserVolume): $CancellablePromis
return $Call.ByID(1375836663, desiredVolume); 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. * TrackLengthInSeconds returns the duration of the current track.
*/ */
@@ -113,14 +113,6 @@ export function Next(): $CancellablePromise<void> {
return $Call.ByID(1968784044); return $Call.ByID(1968784044);
} }
/**
* OnPlaybackFinished is called when a track finishes playing naturally.
* This drives the auto-advance behavior and records the play.
*/
export function OnPlaybackFinished(): $CancellablePromise<void> {
return $Call.ByID(2184869763);
}
/** /**
* Play handles a play request by either resuming the current track or * Play handles a play request by either resuming the current track or
* starting playback from the beginning of the queue. When a track is * starting playback from the beginning of the queue. When a track is
+263 -34
View File
@@ -104,6 +104,51 @@ p {
flex: 0 1 320px; flex: 0 1 320px;
} }
/* What the bar gives up when it does not fit is decided by measuring
it (`services/top-bar-fit.ts`, #143). Two rules here are what make
that measurement mean anything.
**Nothing but the search box may shrink.** `scrollWidth` reports a
perfect fit while a child quietly truncates -- #69's trap, one
component over -- and the indicator's label is `text-overflow:
ellipsis`, so it would have absorbed the deficit and hidden it. The
search box is exempt because it shrinks between its 320px basis and
the 200px floor its own stylesheet sets, and a narrower input hides
nothing it was showing. */
.top-bar hgroup,
.top-bar library-filter,
.top-bar job-indicator {
flex-shrink: 0;
}
/* **The wordmark yields its width, not its existence.** It is the
app's top-level heading as well as its brand, and `display: none`
would take a document from one `h1` to none at exactly the widths
where the view's own header is the only thing left saying where you
are. This is `styles/sr-only.css.ts`'s recipe, written out because
that one is a `CSSResult` for shadow roots and this is the light
DOM. */
.top-bar hgroup.yj-collapsed {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* The bar is `justify-content: space-between`, which with four children
spreads them evenly and left back/forward floating in the middle of
nothing. Collecting the free space *after* this one puts the pair
beside the brand, where a browser keeps them, and leaves the
right-hand group exactly as it was. */
.top-bar nav-history {
margin-right: auto;
}
ul { ul {
list-style-type: none; list-style-type: none;
} }
@@ -133,6 +178,23 @@ ul {
.subtitle { .subtitle {
display: none; display: none;
} }
/* Back/forward is Desktop-band chrome (#6), and 900 is the same
line the sidebar's labels and the subtitle are already given up
at -- below it the shell is narrow enough that the header is
what runs out of room first. Measured at 600, the bottom of the
Compact band: the bar is 611px inside a 600px viewport *before*
this component exists (filed separately), and 695px with it, so
keeping it here would be widening a violation of the promise
that nothing scrolls sideways at a supported size.
Nothing is unreachable as a result, which is the rule that
decides it: Alt+Left / Alt+Right are global and every width has
them, the detail views keep their own back buttons, and the
phone additionally has the platform's gesture. */
.top-bar nav-history {
display: none;
}
} }
body div.sidebar { body div.sidebar {
@@ -149,7 +211,39 @@ body div.sidebar {
padding: 0.25em; padding: 0.25em;
background-color: var(--yj-bg-elevated, #343a40); background-color: var(--yj-bg-elevated, #343a40);
display: grid; display: grid;
grid-template-columns: var(--now-playing-width, 320px) 1fr auto; /* Three columns whose outer two are the *same* width, which is what
centres the middle one (#23). It was `var(--now-playing-width) 1fr
auto`, so the transport's centre sat at `W/2 + 140px` in the
middle of the space left over, which is not the same thing and
reads as an alignment mistake at every window size.
The outer width is still `--now-playing-width`, so **the metadata
panel's drag handle keeps meaning something**: widening it takes
room from the transport on both sides at once, symmetrically. An
`1fr 1fr` pair would have centred the transport just as well and
silently made that handle a no-op.
**The cap is what stops that being a regression**, and it was
measured as one first. Reserving the full metadata width on both
sides costs the transport twice: at 800px the outer pair wanted
640 of 800, and the seek bar's track went from 257px to 61px
(and to 0 at 200% text) the control you drag, squeezed out to
centre the buttons above it. So the side tracks are the metadata
width *or a quarter of the bar*, whichever is smaller, which
leaves the drag handle meaningful everywhere it has room to be
and hands the difference to the transport where it does not.
`minmax(0, )` on the outer tracks and `min-content` on the middle
decide who yields when even that is not enough: the metadata and
the end group shrink (both truncate; neither loses an action), and
the transport keeps at least its buttons. Without the `min-content`
floor the middle collapses first, because a `1fr` track's minimum
is `auto` only until something else insists. */
--bar-side: min(var(--now-playing-width, 320px), 25%);
grid-template-columns:
minmax(0, var(--bar-side))
minmax(min-content, 1fr)
minmax(0, var(--bar-side));
align-items: center; align-items: center;
contain: layout style; contain: layout style;
@@ -177,23 +271,44 @@ body div.sidebar {
text-wrap-mode: nowrap; text-wrap-mode: nowrap;
overflow: hidden; overflow: hidden;
p { /* `& p`, not `p`. **A nested rule that begins with a bare
element selector is silently dropped before Chrome 120**
(relaxed nesting), and the phone this app runs on renders
in Chrome 113 -- so this ellipsis, and the two rules
below, have never applied on the device. Nothing fails;
the text simply overflows there. The `&` form is valid in
both, which is why it is used for every element selector
in this file's nested blocks. */
& p {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
} }
now-playing { & now-playing {
overflow: hidden; overflow: hidden;
} }
audio-player { & audio-player {
margin: 0.5em 1em; margin: 0.5em 1em;
min-width: 0;
}
/* The right-hand group, and the thing the left column is matched
against. It is one grid cell rather than two columns because the
centring rule above compares *columns*: volume and the queue
button in separate tracks would make the outer pair unequal by
whatever the volume happens to measure. */
.bar-end {
justify-self: end;
display: flex;
align-items: center;
gap: 0.25em;
min-width: 0;
} }
#queue-button { #queue-button {
justify-self: end;
background: none; background: none;
border: none; border: none;
color: inherit; color: inherit;
@@ -231,6 +346,14 @@ body div.sidebar {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
contain: layout style; contain: layout style;
/* The containing block for the queue panel's overlay mode (plan
018, #24), which spans this box rather than taking width from
the main panel beside it. `contain: layout` already establishes
one; this says so on purpose, so that removing the containment
for a paint reason does not silently reparent the overlay to the
viewport. */
position: relative;
} }
.main-panel { .main-panel {
@@ -289,8 +412,18 @@ body div.sidebar {
=================================================================== */ =================================================================== */
@media (max-width: 599px) { @media (max-width: 599px) {
body { 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: grid-template:
"top-bar" 3.25em "jobs-band" auto
"main-panel" 1fr "main-panel" 1fr
"bottom-bar" auto "bottom-bar" auto
"bottom-nav" auto "bottom-nav" auto
@@ -309,40 +442,55 @@ body div.sidebar {
grid-area: bottom-nav; grid-area: bottom-nav;
} }
/* The 2em gutters are half a thumb each at this width, and the /* The bar is out of the layout, and out of it the way the *wordmark*
subtitle is already gone from 900 down. 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 Its four *controls* are `display: none` below, which is what
minimum is `auto` -- its content -- so a header whose children keeps them out of the tab order -- a visually-hidden container is
ask for 580px makes the *body* 580px wide inside a 360px still focusable, and tabbing into a search box nobody can see is
viewport, and `overflow-x: hidden` then hides the right-hand worse than not having one.
third of the app rather than fitting it. Every box between the
viewport and the content that must shrink needs this. */ 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 { .top-bar {
padding-left: 0.75em; position: absolute;
padding-right: 0.75em; width: 1px;
gap: 0.5em; height: 1px;
min-width: 0; padding: 0;
margin: -1px;
overflow: hidden; 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, .content-area,
.main-panel, .main-panel,
.bottom-bar { .bottom-bar {
min-width: 0; 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. */
.top-bar library-filter {
display: none;
}
/* The full-screen now-playing view *is* the transport, so the bar /* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to the same thing twice -- visible in a screenshot, invisible to
@@ -356,14 +504,14 @@ body div.sidebar {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar { body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none; display: none;
} }
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
} }
@media (max-width: 599px) { @media (max-width: 599px) {
/* The phone keeps the two-part bar it had: metadata, then the
transport and the queue button. There is no third column to
balance because the centring the desktop does is a luxury of
having room at 360px the metadata needs all of the space the
controls do not. */
.bottom-bar { .bottom-bar {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.25em; gap: 0.25em;
@@ -372,4 +520,85 @@ body div.sidebar {
.bottom-bar audio-player { .bottom-bar audio-player {
margin: 0.25em; margin: 0.25em;
} }
/* Volume stands down here whatever the setting says, because this
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.
**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. */
body job-band {
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);
}
} }
+49 -7
View File
@@ -14,16 +14,37 @@
user is not walked through the header, the library filter, the user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. --> search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a> <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"> <header class="top-bar">
<hgroup> <hgroup>
<h1 class="title">YellowJacket</h1> <h1 class="title">YellowJacket</h1>
<!-- a11y.29: a heading level was being used for type size. --> <!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p> <p class="subtitle">Music how it was meant to bee.</p>
</hgroup> </hgroup>
<!-- Global back/forward (#6). Before the library filter so the
two navigation controls in this bar are adjacent, and after
the brand because that is where a window's chrome ends and
the app's begins. Hidden below 600px by index.css: the
phone has a system back, and this bar has no room. -->
<nav-history></nav-history>
<library-filter></library-filter> <library-filter></library-filter>
<search-bar></search-bar> <search-bar></search-bar>
<job-indicator></job-indicator> <job-indicator></job-indicator>
</header> </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"> <div class="sidebar">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
</div> </div>
@@ -34,16 +55,31 @@
</main> </main>
<queue-panel id="queue-panel"></queue-panel> <queue-panel id="queue-panel"></queue-panel>
</div> </div>
<!-- Three columns, and the outer two are the same width, which is
what makes the middle one *centred* rather than merely in the
middle of what is left (#23). The transport used to sit in a
`320px 1fr auto` grid, so its centre was ~140px right of the
window's.
That is also why the volume moved out of `audio-player` and
into the bar (#42): the transport column has to contain the
transport and nothing else, or "centred" means centred with a
slider bolted to one side. It joins the queue button in
`.bar-end`, whose width is what the left column is matched
against. -->
<footer class="bottom-bar"> <footer class="bottom-bar">
<now-playing></now-playing> <now-playing></now-playing>
<audio-player></audio-player> <audio-player></audio-player>
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false" <div class="bar-end">
id="queue-button"> <volume-control></volume-control>
<!-- ICON_QUEUE in src/utils/icon-language.ts, written out <button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
because this file has no module scope. It was `list`, id="queue-button">
which is the Playlists destination's icon. --> <!-- ICON_QUEUE in src/utils/icon-language.ts, written out
<wa-icon name="bars-staggered"></wa-icon> because this file has no module scope. It was `list`,
</button> which is the Playlists destination's icon. -->
<wa-icon name="bars-staggered"></wa-icon>
</button>
</div>
</footer> </footer>
<!-- The phone's primary navigation, hidden above 600px by <!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason index.css. Eager rather than a chunk, for the reason
@@ -55,6 +91,12 @@
<first-run-wizard></first-run-wizard> <first-run-wizard></first-run-wizard>
<notification-host></notification-host> <notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay> <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> </body>
</html> </html>
+228 -26
View File
@@ -18,12 +18,21 @@
// track-list — index.html renders one, so it is the first paint. // track-list — index.html renders one, so it is the first paint.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import '@components/audio-player/audio-player.ts'; 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';
import '@components/track-list/track-list.ts'; import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts'; import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts'; import '@components/sidebar/app-sidebar.ts';
import '@components/bottom-nav/bottom-nav.ts'; import '@components/bottom-nav/bottom-nav.ts';
import '@components/queue-panel/queue-panel.ts'; import '@components/queue-panel/queue-panel.ts';
import '@components/nav-history/nav-history.ts';
import '@components/search-bar/search-bar.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/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts'; import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts'; import '@components/notifications/notification-host.ts';
@@ -34,12 +43,19 @@ import '@components/confirm-dialog/confirm-dialog.ts';
// not know what is going on. It costs a dialog and a table. // not know what is going on. It costs a dialog and a table.
import '@components/shortcuts-overlay/shortcuts-overlay.ts'; import '@components/shortcuts-overlay/shortcuts-overlay.ts';
import '@components/jobs/job-indicator.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/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons'; import { registerBundledIcons } from './src/icons';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store'; import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
import { historyStore } from '@store/history-store';
import * as Player from '@go/player/player.js'; import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js'; import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js'; import { GetDefaultPage } from '@go/config/config.js';
@@ -51,6 +67,8 @@ import '@store/theme-store';
import './src/services/keyboard-shortcut-service'; import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle'; import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press'; import { installLongPressContextMenu } from '@utils/long-press';
import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit';
import { import {
hasTrackPayload, hasTrackPayload,
getDragPayload, getDragPayload,
@@ -70,6 +88,14 @@ registerBundledIcons();
// on `pointerType === 'touch'` only. // on `pointerType === 'touch'` only.
installLongPressContextMenu(); installLongPressContextMenu();
// 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
// its children are five separate elements; the shell is the only thing
// that can see all five at once.
const topBar = document.querySelector<HTMLElement>('header.top-bar');
if (topBar) installTopBarFit(topBar);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// View caching navigation system // View caching navigation system
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,7 +124,6 @@ const VIEW_TAGS: Record<string, string> = {
explore: 'explore-view', explore: 'explore-view',
autotag: 'autotag-view', autotag: 'autotag-view',
downloads: 'downloads-view', downloads: 'downloads-view',
jobs: 'jobs-view',
settings: 'config-page', settings: 'config-page',
}; };
@@ -116,7 +141,6 @@ const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
explore: () => import('@components/explore-view/explore-view.ts'), explore: () => import('@components/explore-view/explore-view.ts'),
autotag: () => import('@components/autotag-view/autotag-view.ts'), autotag: () => import('@components/autotag-view/autotag-view.ts'),
downloads: () => import('@components/downloads-view/downloads-view.ts'), downloads: () => import('@components/downloads-view/downloads-view.ts'),
jobs: () => import('@components/jobs/jobs-view.ts'),
settings: () => import('@components/config-page/config-page.ts'), settings: () => import('@components/config-page/config-page.ts'),
}; };
@@ -214,49 +238,132 @@ document.addEventListener('navigate', (e: Event) => {
// go through `history.back()` rather than popping `navStack` // go through `history.back()` rather than popping `navStack`
// themselves, so one press cannot consume two entries. // themselves, so one press cannot consume two entries.
/** The navigation an entry stands for. `undefined` on the entry that /** The navigation an entry stands for, and where it sits in this
* predates the app's own routing, which is the one back exits from. */ * session's list. `undefined` on the entry that predates the app's own
type NavState = { yjNav?: { view: string; [key: string]: any } }; * routing, which is the one back exits from. */
type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number };
/** Whether the app's first navigation has been recorded. It *replaces* /** Whether the app's first navigation has been recorded. It *replaces*
* the launch entry rather than pushing, or every launch would cost one * the launch entry rather than pushing, or every launch would cost one
* back press before the app would exit. */ * back press before the app would exit. */
let historyStarted = false; let historyStarted = false;
/** How many entries this session has pushed beyond that first one -- // Back and forward are the *same* `popstate` event -- it carries no
* i.e. how deep back can go while staying inside the app. */ // direction, and the History API exposes neither the current position
let pushedEntries = 0; // nor a reachable depth. So the shell numbers its own entries: the
// index of the one showing, and the highest index reachable from here.
//
// The counter this replaced (`pushedEntries`, one number decremented on
// every pop) could not express forward at all: going forward looked
// exactly like going back again, so two presses of a Forward button
// would have claimed the app was at its root.
/** Index of the entry now showing. 0 is the launch entry, which is
* replaced rather than pushed -- so this is also how deep back can go
* while staying inside the app. */
let currentIndex = 0;
/** The highest index reachable from here: how far forward is left.
* A new navigation truncates the forward list, exactly as a browser
* does, so this is reset to the entry being pushed. */
let maxIndex = 0;
function publishDepth(): void {
historyStore.setDepth(currentIndex > 0, currentIndex < maxIndex);
}
function recordNavigation(detail: { view: string; [key: string]: any }): void { function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_isBack` is bookkeeping, not destination: keeping it in the entry // `_isBack` and `_replace` are bookkeeping, not destination: keeping
// would make a replayed navigation claim to be a back-navigation. // either in the entry would make a replayed navigation claim to be
const { _isBack: _ignored, ...nav } = detail; // one.
const state: NavState = { yjNav: nav }; const { _isBack: _ignored, _replace: replace, ...nav } = detail;
// Still launching: the configured landing page is not a navigation
// *away* from the eager one, it is the same arrival arriving late
// (#142). Pushing it left the app one entry deep before the user
// had touched anything, so the first back press replayed home over
// home -- invisible on desktop until #6 drew a Back button, and on
// Android the press that should have exited the app instead did
// nothing, because `canGoBack()` was true.
//
// Guarded on being at the root rather than on a flag, because
// `GetDefaultPage()` is a backend call and the user can navigate
// while it is in flight: past index 0 this is an ordinary
// navigation, or a slow answer would overwrite an entry they made.
if (historyStarted && replace && currentIndex === 0) {
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
maxIndex = 0;
publishDepth();
return;
}
// Same URL, deliberately: the app has no routes, and a path a // Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all. // reload cannot resolve is worse than no path at all.
if (historyStarted) { if (historyStarted) {
history.pushState(state, ''); currentIndex += 1;
pushedEntries += 1; // Navigating from the middle of the list drops what was ahead
// of it -- there is no longer a forward to go to.
maxIndex = currentIndex;
history.pushState({ yjNav: nav, yjIdx: currentIndex }, '');
} else { } else {
history.replaceState(state, ''); currentIndex = 0;
maxIndex = 0;
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
historyStarted = true; historyStarted = true;
} }
publishDepth();
} }
window.addEventListener('popstate', (e: PopStateEvent) => { window.addEventListener('popstate', (e: PopStateEvent) => {
const nav = (e.state as NavState | null)?.yjNav; const state = e.state as NavState | null;
const nav = state?.yjNav;
// Before the app's first navigation, or an entry somebody else // Before the app's first navigation, or an entry somebody else
// pushed: nothing to restore, and the activity should be free to // pushed: nothing to restore, and the activity should be free to
// finish. // finish.
if (!nav) return; if (!nav) return;
pushedEntries = Math.max(0, pushedEntries - 1); // The entry says where it is, so this works in both directions and
// across a jump of more than one -- which a long-press on a
// browser's back button, and `history.go(-n)`, both produce.
// The fallback is for an entry pushed before this numbering
// existed; it can only be wrong about a control's disabled state,
// never about which view is restored.
currentIndex = state?.yjIdx ?? Math.max(0, currentIndex - 1);
publishDepth();
void handleNavigate({ ...nav, _isBack: true }); 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( async function handleNavigate(
detail: { view: string; [key: string]: any }, detail: { view: string; [key: string]: any },
): Promise<void> { ): Promise<void> {
@@ -268,6 +375,25 @@ async function handleNavigate(
if (!detail._isBack) recordNavigation(detail); 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 // Bookkeeping stays synchronous with the click: the search box's
// scope and the active-view attribute describe the navigation that // scope and the active-view attribute describe the navigation that
// was *asked for*, and are what the rest of the app and the e2e // was *asked for*, and are what the rest of the app and the e2e
@@ -279,6 +405,20 @@ async function handleNavigate(
// attribute keeps e2e selectors semantic instead of structural. // attribute keeps e2e selectors semantic instead of structural.
mainContent.dataset.activeView = view; mainContent.dataset.activeView = view;
// And publishing it as a *value* is what the nav components read.
// They used to learn the active view from the `navigate` event,
// which only the outbound path dispatches -- so a back-navigation
// left both of them highlighting the view it had just left (#72).
// Re-dispatching `navigate` here is not the fix: this file is a
// document listener for it, so that is an infinite loop, and
// "please go to X" is not the statement being made.
//
// `view in VIEW_TAGS` is the primary/detail split, and it is passed
// rather than re-derived because this table is where it is written
// down. A detail view therefore leaves the tab it was opened from
// lit, which is what the report asks for.
activeViewStore.setView(view, view in VIEW_TAGS);
// --- Primary (cacheable) views ---------------------------------------- // --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) { if (view in VIEW_TAGS) {
// Remove any active detail view first // Remove any active detail view first
@@ -308,6 +448,18 @@ async function handleNavigate(
deactivateView(currentViewEl); deactivateView(currentViewEl);
} }
target.classList.remove('view-hidden'); target.classList.remove('view-hidden');
// A primary view is cached, so there is no construction to
// hand a payload to the way a detail view gets one below. The
// one navigation that carries something is the album page's
// "Review in Autotag", which has to land on *that* album: the
// request goes on as an attribute and `autotag-view` consumes
// it (removes it) once acted on, or every later visit would
// reopen a folder the user finished with long ago.
if (view === 'autotag' && typeof detail.groupKey === 'string') {
target.setAttribute('group-key', detail.groupKey);
}
// A freshly created view was appended hidden, so it did not // A freshly created view was appended hidden, so it did not
// self-activate on connection; a cached one was deactivated on // self-activate on connection; a cached one was deactivated on
// the way out. Either way this is the call that starts it. // the way out. Either way this is the call that starts it.
@@ -485,7 +637,18 @@ function schedule(fn: () => void): void {
// anyway would leave the app: the depth check is what stops a stray // anyway would leave the app: the depth check is what stops a stray
// `navigate-back` closing it. // `navigate-back` closing it.
document.addEventListener('navigate-back', () => { document.addEventListener('navigate-back', () => {
if (pushedEntries > 0) history.back(); if (currentIndex > 0) history.back();
});
// Forward: the other half of #6. The stack was always global -- every
// navigation is an entry and `popstate` restores any of them -- so what
// was missing is a way to ask for one, and a truthful answer to whether
// there is one to ask for. It is guarded for the same reason back is:
// `history.forward()` at the end of the list is silent, so a button
// that offers it when there is nothing there is a button that does
// nothing.
document.addEventListener('navigate-forward', () => {
if (currentIndex < maxIndex) history.forward();
}); });
// Navigate to the user's configured launch page. Falls back to 'home' // Navigate to the user's configured launch page. Falls back to 'home'
@@ -495,14 +658,17 @@ GetDefaultPage()
document.dispatchEvent(new CustomEvent('navigate', { document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true, bubbles: true,
composed: true, composed: true,
detail: { view: view || 'home' }, // Part of launching, not a navigation away from the eager
// 'home' above: it replaces that entry rather than
// stacking on it (#142).
detail: { view: view || 'home', _replace: true },
})); }));
}) })
.catch(() => { .catch(() => {
document.dispatchEvent(new CustomEvent('navigate', { document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true, bubbles: true,
composed: true, composed: true,
detail: { view: 'home' }, detail: { view: 'home', _replace: true },
})); }));
}); });
@@ -512,13 +678,17 @@ const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
if (queueButton && queuePanel) { if (queueButton && queuePanel) {
queueButton.addEventListener('click', () => { queueButton.addEventListener('click', () => {
const isOpen = queuePanel.hasAttribute('open'); if (queuePanel.hasAttribute('open')) {
// Closing goes through the panel either way; where the queue
if (isOpen) { // 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'); queuePanel.removeAttribute('open');
} else {
queuePanel.setAttribute('open', ''); return;
} }
openQueue();
}); });
// The button says whether the panel is open, and it learns that // The button says whether the panel is open, and it learns that
@@ -536,7 +706,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, attributes: true,
attributeFilter: ['open'], attributeFilter: ['open'],
}); });
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>

After

Width:  |  Height:  |  Size: 532 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 256a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm168 0a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zm224-56a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>

After

Width:  |  Height:  |  Size: 443 B

@@ -3,7 +3,6 @@ import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import './controls/player-controls'; import './controls/player-controls';
import './seekbar/seek-bar'; import './seekbar/seek-bar';
import './volume-control/volume-control';
import '../notifications/inline-notice'; import '../notifications/inline-notice';
import { PlayerRegion } from '@store/player-store'; import { PlayerRegion } from '@store/player-store';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
@@ -30,6 +29,7 @@ export class AudioPlayer extends LitElement {
.player-main { .player-main {
flex: 1; flex: 1;
min-width: 0;
} }
/* The phone transport (plan 016 B2): the buttons, and nothing /* The phone transport (plan 016 B2): the buttons, and nothing
@@ -37,14 +37,17 @@ export class AudioPlayer extends LitElement {
viewport, not by the host, so this is the component saying what viewport, not by the host, so this is the component saying what
it drops at phone width rather than the shell reaching in. it drops at phone width rather than the shell reaching in.
Volume goes because the hardware keys own it on a phone --
Android routes them to the media stream, which is also why
mediacontrols' Android handler implements no volume callback.
The seek bar goes because a 4px-tall target dragged with a thumb The seek bar goes because a 4px-tall target dragged with a thumb
is not a seek control; seeking belongs to the full-screen is not a seek control; seeking belongs to the full-screen
now-playing view, which is the next phase. */ now-playing view.
Volume used to go from here too, and now goes from index.css
instead: #42 moved the control out of this component and into
the bar, so the shell is what can hide it. The reason is
unchanged -- the hardware keys own volume on a phone, which is
also why mediacontrols' Android handler implements no volume
callback. */
@media (max-width: 599px) { @media (max-width: 599px) {
volume-control,
seek-bar { seek-bar {
display: none; display: none;
} }
@@ -65,7 +68,6 @@ export class AudioPlayer extends LitElement {
<seek-bar></seek-bar> <seek-bar></seek-bar>
</div> </div>
</div> </div>
<volume-control></volume-control>
</div> </div>
`; `;
} }
@@ -1,22 +1,77 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store'; import type { RepeatMode } from '@store/queue-store';
import { designTokens } from '../../../styles/tokens.css'; 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') @customElement('player-controls')
export class PlayerControls extends LitElement { export class PlayerControls extends LitElement {
private player = new PlayerController(this); private player = new PlayerController(this);
private unsubscribeQueue?: () => void; 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 shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off'; @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 { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
const s = queueStore.getState(); const s = queueStore.getState();
this.shuffleMode = s.shuffleMode; this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode; this.repeatMode = s.repeatMode;
@@ -37,6 +92,7 @@ export class PlayerControls extends LitElement {
override disconnectedCallback(): void { override disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this.unsubscribeQueue?.(); this.unsubscribeQueue?.();
this.media?.removeEventListener('change', this.onMedia);
} }
static override styles = [designTokens, css` static override styles = [designTokens, css`
@@ -58,6 +114,99 @@ export class PlayerControls extends LitElement {
justify-content: center; 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 { button:hover {
color: var(--yj-accent-text, #ffd43b); color: var(--yj-accent-text, #ffd43b);
} }
@@ -104,46 +253,107 @@ export class PlayerControls extends LitElement {
queueStore.cycleRepeat(); queueStore.cycleRepeat();
}; };
override render() { /** Shuffle. Secondary: it changes how the queue behaves rather than
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play'; * what is playing now. */
const playOrPauseHandler = this.player.isPlaying private renderShuffle() {
? this.handlePauseClick return html`
: this.handlePlayClick; <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 repeatMode = this.repeatMode;
const repeatClasses = [ const repeatClasses = [
repeatMode !== 'off' ? 'active' : '', repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '', repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' '); ].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` return html`
<div id="player-control-buttons"> <div id="player-control-buttons">
<button ${this.slim ? nothing : this.renderShuffle()}
class=${shuffleClass} ${this.renderPrimary()}
aria-label="Shuffle" ${this.slim ? nothing : this.renderRepeat()}
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>
</div> </div>
`; `;
} }
@@ -22,6 +22,24 @@ export class SeekBar extends LitElement {
@state() @state()
private seekValue: number = 0; 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. */ /** Whether the right-hand clock shows time remaining or total. */
@state() @state()
private showRemaining: boolean = true; private showRemaining: boolean = true;
@@ -133,6 +151,7 @@ export class SeekBar extends LitElement {
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.stopProgress(); this.stopProgress();
this.endDrag();
} }
override updated() { override updated() {
@@ -154,18 +173,33 @@ export class SeekBar extends LitElement {
// A report for a track that is no longer loaded is stale by // A report for a track that is no longer loaded is stale by
// definition: the change id is the only thing that distinguishes // definition: the change id is the only thing that distinguishes
// it, since the same file can play twice in a row. // 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 position = this.player.position;
const forThisTrack = const forThisTrack =
position !== null && position.trackChangeId === currentChangeId; 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.previousPositionSeq = position.seq;
this.seekValue = position.positionSeconds; this.seekValue = position.positionSeconds;
this.stopProgress(); this.stopProgress();
} }
// Start/stop progress interval based on playback state // One owner for the interval, and this is it. Every other place
if (this.isPlaying && this.hasTrack) { // 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(); this.startProgress();
} else { } else {
this.stopProgress(); this.stopProgress();
@@ -210,18 +244,48 @@ export class SeekBar extends LitElement {
private handleChange(e: Event) { private handleChange(e: Event) {
const newSeekVal = (e.target as WaSlider).value; const newSeekVal = (e.target as WaSlider).value;
this.endDrag();
this.setSeekValue(newSeekVal); this.setSeekValue(newSeekVal);
this.player.seek(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 endDrag = () => {
private handleInput() { document.removeEventListener('pointerup', this.endDrag);
this.stopProgress(); document.removeEventListener('pointercancel', this.endDrag);
} document.removeEventListener('touchend', this.endDrag);
document.removeEventListener('touchcancel', this.endDrag);
this.dragging = false;
};
private setSeekValue(val: number) { private setSeekValue(val: number) {
if (val < 0) val = 0; if (val < 0) val = 0;
@@ -1,9 +1,10 @@
import { LitElement, html, css } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/slider/slider.js'; import '@awesome.me/webawesome/dist/components/slider/slider.js';
import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js'; import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.js';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { volumeStyleStore } from '@store/volume-style-store';
import { designTokens } from '../../../styles/tokens.css'; import { designTokens } from '../../../styles/tokens.css';
import { waSliderLabel } from '../../../styles/wa-slider-label.css'; import { waSliderLabel } from '../../../styles/wa-slider-label.css';
@@ -22,6 +23,32 @@ export class VolumeControl extends LitElement {
@state() @state()
private showSlider = false; private showSlider = false;
/** Whether this is the click-to-open popup rather than a slider. */
@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. // Locally-tracked volume while the user is actively dragging or scrolling.
// The store's volume only updates once the backend echoes VolumeChanged // The store's volume only updates once the backend echoes VolumeChanged
// (which we debounce), so we track intent here for responsive UI and to let // (which we debounce), so we track intent here for responsive UI and to let
@@ -36,6 +63,13 @@ export class VolumeControl extends LitElement {
align-items: center; 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 { button {
background: none; background: none;
border: none; border: none;
@@ -86,11 +120,31 @@ export class VolumeControl extends LitElement {
--thumb-height: 16px; --thumb-height: 16px;
} }
wa-slider::part(track) { .volume-popup wa-slider::part(track) {
background: var(--yj-text-primary, white); background: var(--yj-text-primary, white);
height: 120px; height: 120px;
} }
/* The inline slider (#42). It is the default now, so the width is
a real layout decision rather than a detail: 5em is wide enough
to aim at and narrow enough that the bottom bar's *outer*
columns stay equal without squeezing the transport which is
the arrangement #23 depends on.
flex-shrink: 0 for the reason the top bar's children have it
(#143): a control that quietly gets narrower under pressure
hides the fact that the bar has run out of room. This one stands
down at phone width instead, in index.css, where the shell can
see the viewport. */
.inline-slider {
width: 5em;
flex-shrink: 0;
}
.inline-slider::part(track) {
background: var(--yj-text-primary, white);
}
wa-slider::part(indicator) { wa-slider::part(indicator) {
background: var(--yj-accent, yellow); background: var(--yj-accent, yellow);
} }
@@ -122,8 +176,26 @@ export class VolumeControl extends LitElement {
// LIFECYCLE // LIFECYCLE
// =================================================================== // ===================================================================
override connectedCallback() {
super.connectedCallback();
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();
}
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.unsubscribeStyle?.();
document.removeEventListener('click', this.boundHandleOutsideClick); document.removeEventListener('click', this.boundHandleOutsideClick);
clearTimeout(this.volumeDebounceTimer); clearTimeout(this.volumeDebounceTimer);
} }
@@ -154,10 +226,12 @@ export class VolumeControl extends LitElement {
private handleOutsideClick(e: Event) { private handleOutsideClick(e: Event) {
const path = e.composedPath(); const path = e.composedPath();
if (!path.includes(this)) { if (!path.includes(this)) this.closeSlider();
this.showSlider = false; }
document.removeEventListener('click', this.boundHandleOutsideClick);
} private closeSlider() {
this.showSlider = false;
document.removeEventListener('click', this.boundHandleOutsideClick);
} }
private handleInput(e: Event) { private handleInput(e: Event) {
@@ -189,21 +263,67 @@ export class VolumeControl extends LitElement {
// RENDER // 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() { override render() {
if (!this.available) return nothing;
const muted = this.player.muted; const muted = this.player.muted;
// Inline, the icon is the mute toggle rather than a disclosure:
// there is nothing left to disclose, and a button that opens a
// popup containing the slider already beside it would be a control
// whose only effect is to duplicate its neighbour.
const iconAction = this.popup
? this.toggleSlider
: () => this.player.toggleMute();
const iconLabel = this.popup
? muted
? 'Muted'
: `Volume ${this.currentVolume}%`
: muted
? 'Unmute'
: 'Mute';
return html` return html`
<button <button
class=${muted ? 'muted' : ''} class=${muted ? 'muted' : ''}
title=${muted ? 'Muted — click for volume' : 'Volume'} title=${muted ? 'Muted — click for volume' : 'Volume'}
aria-label=${muted ? 'Muted' : `Volume ${this.currentVolume}%`} aria-label=${iconLabel}
data-muted=${muted ? 'true' : 'false'} data-muted=${muted ? 'true' : 'false'}
@click="${this.toggleSlider}" @click="${iconAction}"
@wheel="${this.handleWheel}" @wheel="${this.handleWheel}"
> >
<wa-icon name=${this.volumeIcon}></wa-icon> <wa-icon name=${this.volumeIcon}></wa-icon>
</button> </button>
${this.showSlider ${!this.popup
? html`
<wa-slider
class="inline-slider ${muted ? 'muted' : ''}"
label="Volume"
min="0"
max="100"
.value="${this.currentVolume}"
@input="${this.handleInput}"
@wheel="${this.handleWheel}"
></wa-slider>
`
: ''}
${this.popup && this.showSlider
? html` ? html`
<div <div
class="volume-popup ${muted ? 'muted' : ''}" class="volume-popup ${muted ? 'muted' : ''}"
@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog'; import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js'; import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@components/jobs/job-panel';
import { list } from '@utils/binding'; import { list } from '@utils/binding';
type PendingItem = autotagservice.PendingItem; type PendingItem = autotagservice.PendingItem;
@@ -135,8 +136,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
} }
.header { /* The header and the apply-job panel share the header row.
A wrapper rather than a third grid row, because the panel
is display:none while nothing is applying and a grid
row would still spend the container's gap on it -- the
idle case, which is nearly always. */
.header-area {
grid-area: header; grid-area: header;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -1315,13 +1328,40 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// the page only needs the folder list, which is local and may // the page only needs the folder list, which is local and may
// have moved while the page was away. // have moved while the page was away.
if (this.queueStarted) { if (this.queueStarted) {
void this.loadFolders(); void this.loadFolders().then(() => this.openRequestedFolder());
} else { } else {
this.queueStarted = true; this.queueStarted = true;
void this.startQueue(); void this.startQueue().then(() => this.openRequestedFolder());
} }
} }
/**
* Open the folder somebody navigated here to look at.
*
* The album page's "Review in Autotag" has to land on *that*
* album. The queue is sorted by score so the intended folder is
* often near the top, but "often" is a link that sometimes opens
* the wrong album, which is worse than no link.
*
* It is an attribute rather than a property because this is a
* **cached primary view**: `index.ts` creates it once and reuses
* it, so there is no construction to pass a value to. Which is
* also why the request is *consumed* the attribute is removed
* once acted on, or every later visit to Autotag would reopen an
* album the user finished with three navigations ago.
*/
private openRequestedFolder(): void {
const requested = this.getAttribute('group-key');
if (!requested) return;
this.removeAttribute('group-key');
if (this.current?.groupKey === requested) return;
void this.selectFolder(requested);
}
protected override onViewDeactivate(): void { protected override onViewDeactivate(): void {
this.unsubscribeLibraryStore?.(); this.unsubscribeLibraryStore?.();
this.unsubscribeLibraryStore = undefined; this.unsubscribeLibraryStore = undefined;
@@ -3180,7 +3220,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// sees a blank full-screen "Loading\u2026". // sees a blank full-screen "Loading\u2026".
return html` return html`
<div class="root"> <div class="root">
${this.renderHeader()} <div class="header-area">
${this.renderHeader()}
<!--
Applying rewrites tags on disk, and until #27 the
only way to stop a run was the Jobs tab or the
header popover. The per-album ring says work is
happening; this is what can stop it, and what has
the log when it goes wrong.
-->
<job-panel
kinds="autotag-apply"
heading="Applying tags"
></job-panel>
</div>
${this.renderFolderSidebar()} ${this.renderFolderSidebar()}
${this.renderMain()} ${this.renderMain()}
</div> </div>
@@ -7,6 +7,8 @@ import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js'; import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog'; import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language'; import { ICON_PLAYLIST } from '@utils/icon-language';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
type View = 'home' | 'albums' | 'tracks' | 'playlists'; type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -114,8 +116,35 @@ export class BottomNav extends LitElement {
} }
`]; `];
@state() /**
private activeView = 'home'; * Which tab is lit, read from the shell rather than tracked here.
*
* This was a `@state()` field set from the `navigate` event, which
* only the outbound path dispatches -- so backing out of a detail
* view left the highlight wherever it had been (#72). It had no
* equivalent of `app-sidebar`'s `navItems.some(...)` guard either,
* so a detail view set it to a name matching no tab and *nothing*
* was lit; that asymmetry is why one nav looked broken and the
* other looked fine. The store answers both: a detail view leaves
* the tab it was opened from lit, in both components.
*/
private activeCtrl = new ActiveViewController(this);
/**
* The tab bar honours the sidebar's toggles (#25), and the reason is
* inside this component rather than a general rule about phones.
* `PHONE_COLUMN_IDS` is the precedent for "what a phone shows is a
* different question", and it would apply here too -- except that
* "More" opens the *same* `<app-sidebar>`, which filters. An
* unfiltered bar would therefore contradict its own drawer, one tap
* apart, and a destination the user switched off is off wherever it
* is offered.
*
* Which four tabs remains plan 016's committed subset; this only
* removes from it. Hiding all four leaves "More", which is always
* present and reaches everything.
*/
private visibilityCtrl = new ViewVisibilityController(this);
/** /**
* Whether the drawer has been asked for. * Whether the drawer has been asked for.
@@ -167,12 +196,9 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer); nameDialog(this.drawer);
} }
private onGlobalNavigate = (e: Event) => { private onGlobalNavigate = () => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
// A navigation from inside the drawer is the drawer's job done. // A navigation from inside the drawer is the drawer's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false; this.drawerOpen = false;
}; };
@@ -202,13 +228,17 @@ export class BottomNav extends LitElement {
return html` return html`
<nav aria-label="Primary"> <nav aria-label="Primary">
<ul> <ul>
${BottomNav.TABS.map((tab) => html` ${BottomNav.TABS
.filter((tab) => this.visibilityCtrl.visible(tab.id))
.map((tab) => html`
<li> <li>
<button <button
type="button" type="button"
class=${this.activeView === tab.id ? 'active' : ''} class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
data-testid="tab-${tab.id}" data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id aria-current=${this.activeCtrl.isActive(tab.id)
? 'page' ? 'page'
: 'false'} : 'false'}
@click=${() => this.navigate(tab.id)} @click=${() => this.navigate(tab.id)}
@@ -9,7 +9,13 @@ import {
RemoveLibrary, RemoveLibrary,
GetRemovalImpact, GetRemovalImpact,
GetAllLibrariesWithTrackCounts, GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js'; } from '@go/library/library.js';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import '@components/jobs/job-panel';
import { import {
GetScanConcurrency, GetScanConcurrency,
SetScanConcurrency, SetScanConcurrency,
@@ -19,6 +25,8 @@ import {
SetQueueFallback, SetQueueFallback,
GetAllowMeteredCatalogDownload, GetAllowMeteredCatalogDownload,
SetAllowMeteredCatalogDownload, SetAllowMeteredCatalogDownload,
GetPopupVolume,
SetPopupVolume,
} from '@go/config/config.js'; } from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js'; import { GetIndexStatus } from '@go/explore/service.js';
import { notificationStore } from '@store/notification-store'; import { notificationStore } from '@store/notification-store';
@@ -27,6 +35,9 @@ import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller'; import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller'; import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller'; import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import { downloadStore } from '@store/download-store';
import { GetAllPlaylists } from '@go/playlist/service.js'; import { GetAllPlaylists } from '@go/playlist/service.js';
import type * as playlist from '@go/playlist/models.js'; import type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events'; import { Events } from '../../events';
@@ -45,6 +56,10 @@ import {
import './config-field'; import './config-field';
import './config-section'; 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 './download-clients';
import './shortcut-capture'; import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog'; import { confirmAction } from '../confirm-dialog/confirm-dialog';
@@ -71,6 +86,27 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Favorites controller --- // --- Favorites controller ---
private favCtrl = new FavoritesController(this); private favCtrl = new FavoritesController(this);
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
/**
* The job snapshot, for the per-library scan status (#27).
*
* Held as state rather than read from the store in `render()` so
* Lit sees the dependency: the store notifies, and a getter read
* inside a template is not a reactive input.
*/
@state() private jobs: Job[] = [];
/**
* Set between pressing a scan button and the job snapshot that
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
* is long enough for a second click to start a second scan.
*/
@state() private startingScan = false;
private unsubscribeJobs: (() => void) | null = null;
// --- Shortcuts controller --- // --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this); private shortcutsCtrl = new ShortcutsController(this);
@@ -95,6 +131,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@state() private concurrencyMode = 'auto'; @state() private concurrencyMode = 'auto';
@state() private defaultPage = 'home'; @state() private defaultPage = 'home';
@state() private queueFallback = 'favorites'; @state() private queueFallback = 'favorites';
@state() private popupVolume = false;
@state() private indexStatus: explore.IndexStatus | null = null; @state() private indexStatus: explore.IndexStatus | null = null;
/** Three states, not one: the panel used to say "Loading status" /** Three states, not one: the panel used to say "Loading status"
* for the entire session, because the only thing that ever set * for the entire session, because the only thing that ever set
@@ -197,6 +235,42 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex-wrap: wrap; 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 { .save-row {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
@@ -469,6 +543,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex: 1; flex: 1;
} }
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
}
.column-arrows { .column-arrows {
display: flex; display: flex;
gap: 0.15em; gap: 0.15em;
@@ -852,8 +932,14 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
this.scrollMode = this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
// Scan progress lives in the jobs panel now — this page only // Scanning is started and watched here (#27), so the job
// needs to know when the library list itself changes. // snapshot is a live input to this page.
this.unsubscribeJobs = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
this.cancelLibraryAdded = EventsOn( this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded, Events.LibraryAdded,
() => void this.loadLibraries(), () => void this.loadLibraries(),
@@ -884,6 +970,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
} }
protected override onViewDeactivate(): void { protected override onViewDeactivate(): void {
this.unsubscribeJobs?.();
this.unsubscribeJobs = null;
this.cancelLibraryAdded?.(); this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.(); this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.(); this.cancelLibraryRemoved?.();
@@ -893,20 +982,28 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
private async loadLibraries(): Promise<void> { private async loadLibraries(): Promise<void> {
try { try {
const [libs, mode, defaultPage, queueFallback, allowMetered] = const [
await Promise.all([ libs,
GetAllLibrariesWithTrackCounts(), mode,
GetScanConcurrency(), defaultPage,
GetDefaultPage(), queueFallback,
GetQueueFallback(), allowMetered,
GetAllowMeteredCatalogDownload(), popupVolume,
]); ] = await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
GetAllowMeteredCatalogDownload(),
GetPopupVolume(),
]);
this.libraries = libs ?? []; this.libraries = libs ?? [];
this.concurrencyMode = mode; this.concurrencyMode = mode;
this.defaultPage = defaultPage; this.defaultPage = defaultPage;
this.queueFallback = queueFallback; this.queueFallback = queueFallback;
this.allowMeteredCatalogDownload = allowMetered; this.allowMeteredCatalogDownload = allowMetered;
this.popupVolume = popupVolume;
} catch (err) { } catch (err) {
console.error( console.error(
@@ -1084,6 +1181,133 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
} }
} }
// ===================================================================
// SCANNING (#27 — back from the Jobs tab)
// ===================================================================
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return this.jobs.find((job) => job.id === `scan:${id}`);
}
/** The status line under a library name while it is being scanned. */
private libraryScanStatus(id: number): string | null {
const job = this.jobForLibrary(id);
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private get anyScanning(): boolean {
return this.libraries.some(
(lib) => this.libraryScanStatus(lib.id) !== null,
);
}
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.startingScan) return;
this.startingScan = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.startingScan = false;
}
}
private handleScanLibrary = (id: number): void => {
this.activeMenuId = null;
void this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => this.handleScanLibrary(id),
);
};
private handleScanAll = (): void => {
void this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => this.handleScanAll(),
);
};
private handleFullRescan = async (): Promise<void> => {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded '
+ 'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up '
+ 'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.handleFullRescan(),
);
};
private handleViewToggle = (
view: string,
visible: boolean,
): void => {
this.viewsCtrl
.setVisible(view, visible)
.catch((err: unknown) => {
console.error('Failed to save view visibility:', err);
notificationStore.transient({
key: 'view-visibility',
text: `Could not change which views are shown. ${describeError(err)}`,
detail: String(err),
});
// The checkbox has already flipped itself; the store is
// the truth, so redraw from it.
this.requestUpdate();
});
};
private handleDefaultPageChange = ( private handleDefaultPageChange = (
e: CustomEvent<ConfigFieldChangeEvent>, e: CustomEvent<ConfigFieldChangeEvent>,
): void => { ): void => {
@@ -1428,6 +1652,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
--> -->
${this.renderLibrarySection()} ${this.renderLibrarySection()}
${this.renderGeneralSection()} ${this.renderGeneralSection()}
${this.renderNavigationSection()}
${this.renderNowPlayingSection()} ${this.renderNowPlayingSection()}
${this.renderThemeSection()} ${this.renderThemeSection()}
${this.renderTrackListSection()} ${this.renderTrackListSection()}
@@ -1522,6 +1747,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.allowMeteredCatalogDownload} .value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange} @config-change=${this.handleAllowMeteredChange}
></config-field> ></config-field>
<!--
The tier list above says what the build is *doing*;
this says it is a job, and gives it the pause, cancel
and log the tier list never had (#27). Cancelling one
still asks first that confirmation is inside
applyJobControl, keyed on the kind, which is why
this embeds the shared rows rather than drawing its
own.
-->
<job-panel
kinds="index-build,catalog-enrich"
heading="Index jobs"
></job-panel>
</config-section> </config-section>
`; `;
} }
@@ -1642,18 +1881,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
description: description:
'The page the app opens to on launch.', 'The page the app opens to on launch.',
type: 'select' as const, type: 'select' as const,
options: [ // Derived, not written out: this list was a
{ value: 'home', label: 'Home' }, // second copy of the launchable set, and #27
{ value: 'tracks', label: 'Tracks' }, // removing a destination is exactly the change
{ value: 'albums', label: 'Albums' }, // that would have left the two disagreeing.
{ value: 'artists', label: 'Artists' }, // Settings is excluded because it is the one
{ value: 'genres', label: 'Genres' }, // view the backend refuses to launch into.
{ value: 'playlists', label: 'Playlists' }, options: VIEW_META
{ value: 'explore', label: 'Explore' }, .filter((v) => v.alwaysShown !== true)
{ value: 'downloads', label: 'Downloads' }, .map((v) => ({ value: v.id, label: v.label })),
{ value: 'autotag', label: 'Autotag' },
{ value: 'jobs', label: 'Jobs' },
],
}} }}
.value=${this.defaultPage} .value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange} @config-change=${this.handleDefaultPageChange}
@@ -1674,6 +1910,120 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.value=${this.queueFallback} .value=${this.queueFallback}
@config-change=${this.handleQueueFallbackChange} @config-change=${this.handleQueueFallbackChange}
></config-field> ></config-field>
<config-field
.schema=${{
key: 'popupVolume',
label: 'Volume opens in a popup',
description:
'Off, the volume slider is always visible in the '
+ 'player bar. On, it hides behind the speaker '
+ 'icon. The slider stands down on a phone either '
+ 'way, where the hardware keys own volume.',
type: 'toggle' as const,
}}
.value=${this.popupVolume}
@config-change=${this.handlePopupVolumeChange}
></config-field>
</config-section>
`;
}
/**
* The volume control's presentation (#42).
*
* In General rather than beside the theme because it is about the
* transport's behaviour rather than its colours, and next to "When
* the Queue Ends" because both are answers to "how should the
* player behave".
*/
private handlePopupVolumeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const popup = Boolean(e.detail.value);
const previous = this.popupVolume;
this.popupVolume = popup;
void SetPopupVolume(popup).catch((err: unknown) => {
console.error('failed to save the volume control setting', err);
this.popupVolume = previous;
notificationStore.transient({
key: 'popup-volume-setting',
title: 'Setting not saved',
text: describeError(err, 'That setting could not be saved.'),
});
});
};
// --- Navigation section ---
/**
* Which destinations the sidebar and the phone's tab bar offer.
*
* Two items are drawn but not editable, and both say why in place
* rather than being silently inert. Settings is never hideable --
* the backend refuses it too, because `config.toml` is
* hand-editable. The launch page is not hideable *while it is the
* launch page*, which is a state the user can leave by changing the
* launch page above; refusing is preferable to the alternatives,
* since resetting their launch page silently changes a second thing
* they chose and allowing it lands the app on a page nothing points
* at.
*/
private renderNavigationSection() {
return html`
<config-section
heading="Navigation"
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
>
<ul class="column-list">
${repeat(VIEW_META, (v) => v.id, (v) => {
const checked = this.viewsCtrl.enabled(v.id);
const isLaunchPage = this.defaultPage === v.id;
const locked = v.alwaysShown === true || isLaunchPage;
let note = '';
if (v.alwaysShown === true) {
note = 'Always shown.';
} else if (isLaunchPage) {
note = 'This is the launch page.';
} else if (
v.id === 'downloads' &&
checked &&
!downloadStore.available
) {
// The config says show it and the nav does not, which
// would otherwise read as the checkbox not working.
note = 'Hidden until a download client is configured.';
}
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
.checked=${checked}
?disabled=${locked}
@change=${(e: Event) =>
this.handleViewToggle(
v.id,
(e.target as HTMLInputElement).checked,
)}
/>
<span class="column-label">
${v.label}
</span>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
</li>
`;
})}
</ul>
</config-section> </config-section>
`; `;
} }
@@ -2056,10 +2406,25 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
return html` return html`
<config-section <config-section
heading="Libraries" heading="Libraries"
description="Manage your music library folders. Scanning and its description="Manage your music library folders, and scan them
progress live in the Jobs panel." for new and changed files."
.open=${true} .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"> <div class="scan-actions">
<button <button
class="btn-primary" class="btn-primary"
@@ -2067,6 +2432,23 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
> >
Add Library Add Library
</button> </button>
<button
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleScanAll}
>
Scan All
</button>
<button
class="btn-danger"
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleFullRescan}
>
Full Rescan
</button>
</div> </div>
${this.libraries.length > 0 ${this.libraries.length > 0
@@ -2104,7 +2486,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
<span class="library-count"> <span class="library-count">
${this.removingLibraryId === lib.id ${this.removingLibraryId === lib.id
? 'Removing…' ? 'Removing…'
: html`${lib.trackCount} tracks`} : this.libraryScanStatus(lib.id)
?? html`${lib.trackCount} tracks`}
</span> </span>
<div class="overflow-wrapper"> <div class="overflow-wrapper">
<button <button
@@ -2127,6 +2510,16 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
> >
Rename Rename
</div> </div>
${this.libraryScanStatus(lib.id) === null
? html`
<div
class="overflow-item"
@click=${() => this.handleScanLibrary(lib.id)}
>
Scan now
</div>
`
: nothing}
<div <div
class="overflow-item overflow-item--danger" class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)} @click=${() => void this.handleRemoveClick(lib.id)}
@@ -2170,6 +2563,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange} @config-change=${this.handleConcurrencyChange}
></config-field> ></config-field>
<job-panel
kinds="library-scan"
heading="Scans"
></job-panel>
</config-section> </config-section>
`; `;
} }
@@ -22,6 +22,7 @@ import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error'; import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section'; import './config-section';
import '@components/jobs/job-panel';
import { pickDirectory } from '../../utils/pick-directory'; import { pickDirectory } from '../../utils/pick-directory';
/** /**
@@ -274,6 +275,17 @@ export class DownloadClients extends LitElement {
</wa-button> </wa-button>
</div> </div>
`} `}
<!--
The Downloads view already shows every download's
lifecycle state; what it has never had is pause,
cancel and the log, which the Jobs tab carried (#27).
Renders nothing while nothing is downloading.
-->
<job-panel
kinds="download"
heading="Downloads in progress"
></job-panel>
</config-section> </config-section>
<config-section <config-section
@@ -81,23 +81,55 @@ export class ConfirmDialog extends LitElement {
`, `,
]; ];
/**
* Which question is on screen.
*
* This is a singleton reused for every confirmation in the app,
* and `wa-dialog` reports its close *asynchronously* `open =
* false` starts an animation and `wa-hide` arrives after it. So a
* hide belonging to a question that has already been answered can
* land after the *next* question has opened, and cancel it: the
* user is asked something, the dialog vanishes on its own, and the
* call site is told they said no.
*
* The counter is what tells one question from the next. Every
* close bumps it, and the `wa-hide` handler carries the id its
* template was rendered with.
*/
private askSeq = 0;
/** Ask. Resolves true if the user went ahead. */ /** Ask. Resolves true if the user went ahead. */
ask(request: ConfirmRequest): Promise<boolean> { ask(request: ConfirmRequest): Promise<boolean> {
this.close(false); this.close(false);
const id = ++this.askSeq;
this.request = request; this.request = request;
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
this.settle = resolve; this.settle = resolve;
void this.updateComplete.then(() => { void this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true; // A third question could have arrived while this one
// was waiting for its own render.
if (this.askSeq === id && this.dialog) this.dialog.open = true;
}); });
}); });
} }
private close(ok: boolean): void { /**
* Settle the current question, if `id` still names it.
*
* The button handlers pass nothing and always mean the question on
* screen; only `wa-hide` carries an id, because only `wa-hide` can
* arrive late.
*/
private close(ok: boolean, id = this.askSeq): void {
if (id !== this.askSeq) return;
const settle = this.settle; const settle = this.settle;
this.settle = null; this.settle = null;
this.askSeq++;
if (this.dialog) this.dialog.open = false; if (this.dialog) this.dialog.open = false;
this.request = null; this.request = null;
@@ -118,11 +150,15 @@ export class ConfirmDialog extends LitElement {
if (!request) return nothing; if (!request) return nothing;
// Captured at render time, so the handler answers the question
// it was drawn for and not whichever one is up when it fires.
const id = this.askSeq;
return html` return html`
<wa-dialog <wa-dialog
label=${request.title} label=${request.title}
data-testid="confirm-dialog" data-testid="confirm-dialog"
@wa-hide=${() => this.close(false)} @wa-hide=${() => this.close(false, id)}
> >
<p>${request.message}</p> <p>${request.message}</p>
${request.impact ${request.impact
@@ -246,7 +246,7 @@ export class DownloadPicker extends LitElement {
return html` return html`
<wa-callout variant="success"> <wa-callout variant="success">
Found a clear match and started downloading it. Progress is Found a clear match and started downloading it. Progress is
in the background jobs panel. on the Downloads page.
</wa-callout> </wa-callout>
`; `;
} }
@@ -3,6 +3,7 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js'; import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header'; import '@components/page-header/page-header';
import type { PageAction } from '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store'; import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store'; import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
@@ -246,23 +247,23 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
override render() { override render() {
return html` return html`
<page-header heading="Downloads"> <page-header
${this.tab === 'requests' heading="Downloads"
? html` .actions=${this.tab === 'requests'
<wa-button ? ([
slot="actions" {
size="small" id: 'check-now',
appearance="outlined" label: this.checking
?disabled=${this.checking} ? 'Searching\u2026'
title="Search every download client for everything on this list right now, instead of waiting for the next scheduled check" : 'Check now',
@click=${() => void this.checkNow()} icon: 'rotate',
> disabled: this.checking,
<wa-icon slot="start" name="rotate"></wa-icon> title: 'Search every download client for everything on this list right now, instead of waiting for the next scheduled check',
${this.checking ? 'Searching…' : 'Check now'} onSelect: () => void this.checkNow(),
</wa-button> },
` ] satisfies PageAction[])
: nothing} : []}
</page-header> ></page-header>
<p class="subtitle"> <p class="subtitle">
Music you have requested, and the download attempts that Music you have requested, and the download attempts that
@@ -3,6 +3,7 @@ import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css'; import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import { import {
LookupReleaseGroup, LookupReleaseGroup,
BrowseReleases, BrowseReleases,
@@ -29,12 +30,16 @@ import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js'; import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status'; import { libraryStatusFor } from '@utils/library-status';
import { ICON_AUTOTAG } from '@utils/icon-language';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js'; import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js'; import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import '@awesome.me/webawesome/dist/components/button/button.js'; import '@awesome.me/webawesome/dist/components/button/button.js';
import '../download-picker/download-picker'; import '../download-picker/download-picker';
import { downloadStore } from '../../store/download-store'; import { downloadStore } from '../../store/download-store';
import { MatchForAlbum, ApplyAsync } from '@go/autotagservice/service.js';
import type * as autotagservice from '@go/autotagservice/models.js';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { queueStore } from '../../store/queue-store'; import { queueStore } from '../../store/queue-store';
import type { QueueSource } from '../../store/queue-store'; import type { QueueSource } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store'; import { notificationStore } from '../../store/notification-store';
@@ -174,6 +179,19 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@property({ type: Number, attribute: 'local-album-id' }) @property({ type: Number, attribute: 'local-album-id' })
localAlbumId = 0; localAlbumId = 0;
/**
* A confident autotag match for this album, or null when there is
* none worth mentioning.
*
* The tier behind "confident" is `autotag.ConfidentTier`, decided
* in the backend so this page and strict auto-accept cannot
* disagree about what it means (#28, #90).
*/
@state() private autotagMatch: autotagservice.AlbumMatchView | null = null;
/** True while an apply started from this page is in flight. */
@state() private applyingTags = false;
/* ── Internal state ── */ /* ── Internal state ── */
@state() private releaseGroup: MBReleaseGroup | null = null; @state() private releaseGroup: MBReleaseGroup | null = null;
@@ -197,6 +215,28 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private versionEntries: VersionEntry[] = []; @state() private versionEntries: VersionEntry[] = [];
/** Currently-selected dropdown entry (by VersionEntry.key). */ /** Currently-selected dropdown entry (by VersionEntry.key). */
@state() private selectedVersionKey: string = ''; @state() private selectedVersionKey: string = '';
/**
* The key `buildClusters` defaulted to, kept so the page can tell
* "this is what we picked for you" from "you went and chose this".
*
* Only the second needs saying out loud. With the selector demoted
* to a disclosure below the tracklist, a chosen version is the one
* case where the list on screen is not the one the header
* describes, and nothing else on the page would say so.
*/
@state() private defaultVersionKey: string = '';
/**
* Whether the "Other versions" disclosure is open.
*
* Collapsed by default choosing which pressing you are looking at
* is a metadata-repair task and does not belong above the
* tracklist. It is deliberately *not* closed when the selection
* changes: the user opened it to change something, and a panel that
* shuts on use cannot be used twice.
*/
@state() private versionsOpen = false;
@state() private coverArtURL = ''; @state() private coverArtURL = '';
/** /**
@@ -316,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
srOnly, srOnly,
unownedStyles,
css` css`
:host { :host {
display: flex; display: flex;
@@ -521,7 +562,144 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Version selector ── */ /* ── The autotag suggestion ── */
.autotag-match {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 12px;
padding: 10px 14px;
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 8px;
background: var(--yj-surface-1, rgba(255, 255, 255, 0.04));
}
.autotag-match > wa-icon {
flex-shrink: 0;
font-size: var(--yj-icon-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.autotag-match-text {
margin: 0;
flex: 1;
/* The suggestion sits in a flex row beside its buttons,
* and a grid/flex item's implicit minimum is its
* content without this a long release title pushes
* the actions off the end at phone width. */
min-width: 0;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.autotag-match-text strong {
color: var(--yj-text-primary, #fff);
font-weight: 600;
}
.autotag-match-note {
display: block;
margin-top: 2px;
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
}
.autotag-match-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* ── Other versions (a disclosure, below the tracklist) ── */
.versions {
margin-top: 24px;
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
padding-top: 8px;
}
/* The heading exists so the section is reachable by heading
* navigation; the button inside it is the control. Its own
* type scale is the section header's, reduced this is a
* footnote to the page, not a peer of the tracklist. */
.versions-heading {
margin: 0;
font-size: var(--yj-text-sm);
font-weight: 500;
}
.versions-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 2px;
background: none;
border: none;
color: var(--yj-text-secondary, #b3b3b3);
font: inherit;
text-align: left;
cursor: pointer;
}
.versions-toggle:hover {
color: var(--yj-text-primary, #fff);
}
.versions-toggle:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 4px;
}
.versions-toggle wa-icon {
font-size: var(--yj-icon-xs, 11px);
transition: transform 0.2s ease;
}
.versions-toggle[aria-expanded='false'] wa-icon {
transform: rotate(-90deg);
}
.versions-intro {
margin: 0 0 10px;
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
line-height: 1.4;
}
/* A line above the tracklist, and only after a deliberate
* choice see renderChosenVersion. */
.chosen-version {
margin: 0 0 10px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen-version strong {
color: var(--yj-text-primary, #fff);
font-weight: 600;
}
.chosen-version-reset {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--yj-accent-text, #ffd43b);
text-decoration: underline;
cursor: pointer;
}
.chosen-version-reset:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 2px;
}
.version-selector { .version-selector {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -687,20 +865,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
white-space: nowrap; white-space: nowrap;
} }
/* A track the library does not have, on the pattern a /* The dimming itself is unownedStyles, from
* streaming service uses for something it cannot play: the * utils/ownership.ts, imported above. It was written here
* row stays, dimmed, so the album reads as the album rather * first this tracklist is where the treatment came from
* than as the subset that happens to be here. * and moved out when seven other surfaces had to draw the
* * same thing, because two of them would otherwise have
* The dimming is a colour, so it cannot be the only signal * ended up drawing it slightly differently. (No backticks or
* the row also carries aria-disabled, which is what * apostrophes-as-quotes here: this is inside a tagged
* reaches anyone not seeing it. Secondary rather than * template literal.) */
* tertiary because the row's hover background is
* bgOverlay, which tertiary does not clear. */
.track-row.unowned .track-title {
color: var(--yj-text-secondary, #b3b3b3);
font-weight: 400;
}
/* The request control is offered on every row that has /* The request control is offered on every row that has
* something to request, and is not revealed on hover. * something to request, and is not revealed on hover.
@@ -952,10 +1124,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.releases = []; this.releases = [];
this.versionEntries = []; this.versionEntries = [];
this.selectedVersionKey = ''; this.selectedVersionKey = '';
this.defaultVersionKey = '';
this.versionsOpen = false;
this.showFullTracklist = null; this.showFullTracklist = null;
this.localTracks = []; this.localTracks = [];
this.filePaths = new Map(); this.filePaths = new Map();
this.askedFor = new Set(); this.askedFor = new Set();
this.autotagMatch = null;
// Not awaited: the banner is a bonus and the page must not
// wait on it. It is also the *most* useful on an untagged
// album, which is exactly the page that has least else to
// show, so it is asked for on both branches below rather than
// only the catalog one.
void this.loadAutotagMatch();
// Local-only album (no MBID) — populate entirely from library. // Local-only album (no MBID) — populate entirely from library.
if (!mbid && this.localAlbumId) { if (!mbid && this.localAlbumId) {
@@ -1148,6 +1330,31 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return this.completeness; return this.completeness;
} }
/**
* Ask whether the autotagger already has a confident match here.
*
* It answers from what the background prefetch has already scored
* and makes no MusicBrainz request, so this is safe on page load
* see `MatchForAlbum`. A folder nobody has reached yet answers
* `null`, which is the same as "nothing to say": the banner is a
* bonus, so a failure is a missing suggestion rather than an error
* the user can act on, and it stays in the console.
*/
private async loadAutotagMatch(): Promise<void> {
if (this.localAlbumId <= 0) {
this.autotagMatch = null;
return;
}
try {
this.autotagMatch = await MatchForAlbum(this.localAlbumId);
} catch (err) {
console.error('[explore-album] autotag match lookup failed', err);
this.autotagMatch = null;
}
}
/** /**
* Fetch and set `localTracks` directly by local album id the * Fetch and set `localTracks` directly by local album id the
* definite source of truth, used when nothing else has already * definite source of truth, used when nothing else has already
@@ -1643,6 +1850,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|| standardEntry?.key || standardEntry?.key
|| this.versionEntries[0]?.key || this.versionEntries[0]?.key
|| ''; || '';
// Recorded here rather than derived later: this is the one
// place that knows what "the version we picked" means, and
// recomputing the preference order at the render site would be
// a second copy of it. `handleTracklistScopeChange` rebuilds
// through here too, so the switch does not read as a choice of
// version.
this.defaultVersionKey = this.selectedVersionKey;
} }
/** /**
@@ -2301,9 +2516,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
entity-type="album" entity-type="album"
@catalog-retry=${this.retryCatalog} @catalog-retry=${this.retryCatalog}
></catalog-scope-notice> ></catalog-scope-notice>
${this.renderVersionSelector()} ${this.renderAutotagMatch()}
${this.renderChosenVersion()}
${this.renderTracklistScope()} ${this.renderTracklistScope()}
${this.renderTracklist()} ${this.renderTracklist()}
${this.renderVersionSelector()}
</div> </div>
<track-details></track-details> <track-details></track-details>
`; `;
@@ -2954,26 +3171,230 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Version Selector (R025, R026, R027) ── */ /* ── Version Selector (R025, R026, R027) ── */
/**
* "MusicBrainz has a match for this album."
*
* The complaint this answers is that the user had to notice the
* metadata was missing, then go and hunt the album down on the
* Autotag page so the point is to say it *here*, while they are
* looking at the thing, with something to do about it.
*
* Three things about it are load-bearing.
*
* **Applying is offered only when it would do the whole album.** A
* tagging group is a folder, so a multi-disc album is several, and
* one button that applied to the best-scoring one would leave the
* album holding a mix of old and new tags the exact case the
* app's Blocking notification level exists for. `groupCount` is
* the test, and the answer there is review, not apply.
*
* **The confirm is not a formality.** This rewrites tags on disk
* and cannot be undone, so it goes through `confirmAction()` with
* an impact line that says so in those words.
*
* **The banner does not claim a percentage.** The backend has a
* score and deliberately does not put it in the sentence: 0.95
* reads as a probability and is not one. What the user needs is
* which release it is, which is what the release title and artist
* are for.
*/
private renderAutotagMatch() {
const match = this.autotagMatch;
if (!match) return nothing;
const wholeAlbum = match.groupCount === 1;
return html`
<div class="autotag-match" role="status">
<wa-icon name=${ICON_AUTOTAG} aria-hidden="true"></wa-icon>
<p class="autotag-match-text">
MusicBrainz has a match for this album:
<strong>${match.title}</strong>
${match.artistCredit ? html` by ${match.artistCredit}` : nothing}.
${wholeAlbum
? nothing
: html`<span class="autotag-match-note"
>It is filed as ${match.groupCount} folders here, so
tagging it is a review rather than one
step.</span
>`}
</p>
<div class="autotag-match-actions">
${wholeAlbum
? html`<wa-button
size="small"
variant="brand"
?disabled=${this.applyingTags}
@click=${this.onApplyAutotagMatch}
>Apply tags</wa-button
>`
: nothing}
<wa-button
size="small"
appearance="outlined"
@click=${this.onReviewAutotagMatch}
>Review in Autotag</wa-button
>
</div>
</div>
`;
}
/** Hand the group over to the Autotag page and go there. */
private onReviewAutotagMatch = () => {
const match = this.autotagMatch;
if (!match) return;
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'autotag', groupKey: match.groupKey },
}),
);
};
/**
* Apply the match, after asking.
*
* `ApplyAsync` is the registered-job path, so the work is visible
* in the jobs indicator and cancellable there like every other
* long-running operation this page does not grow a second
* progress surface for it. What it does own is the *acknowledgement*
* that the request was accepted, because the button is here.
*
* The page is not refreshed on completion either: rewriting tags
* emits `TrackMetadataChanged`, which `library-store` answers by
* discarding every cached collection, and this page reloads from
* that like everything else.
*/
private onApplyAutotagMatch = async () => {
const match = this.autotagMatch;
if (!match || this.applyingTags) return;
const ok = await confirmAction({
title: `Tag this album as “${match.title}”?`,
message:
`The ${match.trackCount} files of this album are rewritten to` +
` match the MusicBrainz release${
match.artistCredit ? ` by ${match.artistCredit}` : ''
}.`,
impact:
'This edits the tags in the files on disk and cannot be' +
' undone. Nothing is moved or deleted.',
confirmLabel: 'Apply tags',
});
if (!ok) return;
this.applyingTags = true;
try {
await ApplyAsync(match.groupKey, match.releaseMbid);
// The suggestion has been acted on, so it stops being a
// suggestion immediately rather than sitting there inviting
// a second click while the job runs.
this.autotagMatch = null;
notificationStore.transient({
text: 'Tagging this album — progress is in the jobs indicator.',
});
} catch (err) {
console.error('[explore-album] autotag apply failed', err);
// Persistent rather than transient: the user asked for
// something that did not happen, and retrying is meaningful.
notificationStore.persistent({
text: describeError(
err,
'Those tags could not be applied.',
),
tone: 'error',
});
} finally {
this.applyingTags = false;
}
};
/**
* Which pressing is on screen said only when the user chose it.
*
* The selector is a disclosure below the tracklist now, so nothing
* above the list names the version it came from. That is right for
* the default, which is what the header already describes; it is
* wrong the moment someone picks a different one, because then the
* tracklist and the page disagree and the control that explains it
* is off the bottom of the screen.
*
* `defaultVersionKey` is the whole test. A quiet line that appears
* on every album would be the thing this issue removed, one size
* smaller.
*/
private renderChosenVersion() {
if (this.loadingReleases || this.errorReleases) return nothing;
if (!this.selectedVersionKey) return nothing;
if (this.selectedVersionKey === this.defaultVersionKey) return nothing;
const current = this.currentVersion();
if (!current) return nothing;
return html`
<p class="chosen-version">
Showing <strong>${current.label}</strong>
${current.sublabel}.
<button
type="button"
class="chosen-version-reset"
@click=${this.resetVersion}
>
Use the default version
</button>
</p>
`;
}
/** Back to what `buildClusters` picked, without opening the panel. */
private resetVersion = () => {
if (!this.defaultVersionKey) return;
this.selectedVersionKey = this.defaultVersionKey;
};
/**
* "Other versions" a disclosure, below the tracklist.
*
* Choosing which pressing you are looking at is an advanced,
* metadata-repair task, and it used to sit directly above the
* tracklist with a heading and a paragraph of prose explaining our
* clustering heuristic. It is not removed matching the wrong
* release is a real problem and this is how it gets fixed it is
* demoted (#17).
*
* Two things about the shape are load-bearing, and both are
* `config-section`'s rules rather than new ones. The header is a
* real `<button aria-expanded aria-controls>` inside the heading
* that names the section, so it is reachable by Tab and by heading
* navigation alike. And the body **renders unconditionally and is
* toggled with `hidden`**, because `aria-controls` has to name an
* element that is in the DOM.
*
* The loading and error states this used to own are gone rather
* than moved. Both were unguarded, so they took the primary slot on
* every album regardless of whether there was ever going to be a
* choice: the spinner said the same thing `renderTracklist` was
* already saying about the same fetch, and the error is the one
* `catalog-scope-notice` shows at the top of the page with a retry
* every path that sets `errorReleases` also sets `catalogFailed`,
* which is the only route to `unavailable`. What the tracklist does
* with a failure is now the tracklist's own business.
*/
private renderVersionSelector() { private renderVersionSelector() {
if (this.loadingReleases) { if (this.loadingReleases || this.errorReleases) return nothing;
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-loading">Loading releases\u2026</div>
</section>
`;
}
if (this.errorReleases) {
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
// A dropdown is only a choice if the choices differ. Counting // A dropdown is only a choice if the choices differ. Counting
// *entries* is the wrong test: a release group routinely has // *entries* is the wrong test: a release group routinely has
@@ -2984,7 +3405,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// //
// Distinct *tracklists* is the real question, and it is already // Distinct *tracklists* is the real question, and it is already
// computed: clusters are keyed by tracklist fingerprint. // computed: clusters are keyed by tracklist fingerprint.
if (this.distinctTracklistCount() <= 1) return nothing; const choices = this.distinctTracklistCount();
if (choices <= 1) return nothing;
const aggregateEntries = this.versionEntries.filter( const aggregateEntries = this.versionEntries.filter(
(e) => e.group === 'aggregate', (e) => e.group === 'aggregate',
@@ -2994,35 +3417,59 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
); );
return html` return html`
<div class="version-selector"> <section class="versions">
<div class="version-selector-row"> <h3 class="versions-heading">
<label for="version-select">Version</label> <button
<select type="button"
id="version-select" class="versions-toggle"
@change=${this.handleVersionChange} aria-expanded=${this.versionsOpen ? 'true' : 'false'}
aria-label="Select release version" aria-controls="versions-body"
@click=${this.toggleVersions}
> >
${aggregateEntries.length > 0 <wa-icon name="chevron-down" aria-hidden="true"></wa-icon>
? html` Other versions of this album (${choices})
<optgroup label="Aggregate"> </button>
${aggregateEntries.map((e) => </h3>
this.renderVersionOption(e), <div id="versions-body" ?hidden=${!this.versionsOpen}>
)} <p class="versions-intro">
</optgroup> A release group can have several pressings with
` different tracklists. Pick another if the one
: nothing} above does not match your copy.
<optgroup label="Versions"> </p>
${clusterEntries.map((e) => <div class="version-selector">
this.renderVersionOption(e), <div class="version-selector-row">
)} <label for="version-select">Version</label>
</optgroup> <select
</select> id="version-select"
@change=${this.handleVersionChange}
>
${aggregateEntries.length > 0
? html`
<optgroup label="Aggregate">
${aggregateEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
`
: nothing}
<optgroup label="Versions">
${clusterEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
</select>
</div>
${this.renderVersionMeta()}
</div>
</div> </div>
${this.renderVersionMeta()} </section>
</div>
`; `;
} }
private toggleVersions = () => {
this.versionsOpen = !this.versionsOpen;
};
/** /**
* One option in the version list. * One option in the version list.
* *
@@ -3201,8 +3648,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
`; `;
} }
if (this.errorReleases) { if (this.errorReleases) {
// Error already shown in version selector section // The failure belongs to the list that is missing because
return nothing; // of it. This used to return `nothing` and lean on the
// version selector's own error block to have said it, which
// is precisely the coupling that made demoting the selector
// a rewrite rather than a move: a control in a collapsed
// disclosure cannot be the page's error surface.
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
} }
const current = this.currentVersion(); const current = this.currentVersion();
if (!current) { if (!current) {
@@ -3273,7 +3733,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
aria-disabled=${owned ? 'false' : 'true'} aria-disabled=${owned ? 'false' : 'true'}
aria-label=${owned aria-label=${owned
? `Play “${track.title}` ? `Play “${track.title}`
: `${track.title} — not in your library`} : unownedLabel(track.title, 'track')}
@dblclick=${() => this.onTrackRowDblClick(track)} @dblclick=${() => this.onTrackRowDblClick(track)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, track)} @keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, track)}
@@ -39,7 +39,17 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events'; import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js'; import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor, toggleRequest } from '@utils/library-status'; import {
albumBadgeFor,
libraryStatusFor,
toggleRequest,
} from '@utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store';
import '../catalog-scope-notice/catalog-scope-notice.js'; import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js'; import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store'; import { queueStore } from '../../store/queue-store';
@@ -178,7 +188,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private discoRowSize = 5; @state() private discoRowSize = 5;
private discoObserver?: ResizeObserver; private discoObserver?: ResizeObserver;
@state() private similarExpanded = false; @state() private similarExpanded = false;
private libraryMBIDs = new Set<string>();
/* ── Release prefetch ── */ /* ── Release prefetch ── */
@@ -257,6 +266,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
designTokens, designTokens,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
unownedStyles,
css` css`
:host { :host {
display: flex; display: flex;
@@ -995,6 +1005,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
/** Unsubscribe handle for the requests list. */ /** Unsubscribe handle for the requests list. */
private unsubRequests: (() => void) | null = null; private unsubRequests: (() => void) | null = null;
/** Unsubscribes the "how much of this album is here" repaint. */
private unsubCompleteness: (() => void) | null = null;
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (this.artistMBID || this.localArtistId) { if (this.artistMBID || this.localArtistId) {
@@ -1007,6 +1020,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate()); this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
void downloadStore.init().then(() => this.requestUpdate()); void downloadStore.init().then(() => this.requestUpdate());
// The count behind a partly-held album lands a frame after the
// cards do, since the store batches a screenful into one query.
this.unsubCompleteness = completenessStore.subscribe(() =>
this.requestUpdate(),
);
// A background discography fetch (top tracks / top releases for an // A background discography fetch (top tracks / top releases for an
// artist that wasn't indexed yet) finished — re-fetch those two // artist that wasn't indexed yet) finished — re-fetch those two
// sections, once per artist, so they fill in without the initial // sections, once per artist, so they fill in without the initial
@@ -1045,6 +1064,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
super.disconnectedCallback(); super.disconnectedCallback();
this.unsubRequests?.(); this.unsubRequests?.();
this.unsubRequests = null; this.unsubRequests = null;
this.unsubCompleteness?.();
this.unsubCompleteness = null;
this.unsubDiscogReady?.(); this.unsubDiscogReady?.();
this.unsubSimilarReady?.(); this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer); if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
@@ -1573,10 +1594,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.catalogPending = false; this.catalogPending = false;
} }
// Populate libraryMBIDs from the inLibrary flag (already
// set by the backend via local_release_group_id cross-ref).
this.checkLibrary();
// Batch-resolve cover art for discography (lower priority — loaded after top sections). // Batch-resolve cover art for discography (lower priority — loaded after top sections).
void this.batchResolveThumbnails( void this.batchResolveThumbnails(
rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit })) rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit }))
@@ -1903,23 +1920,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
} }
} }
private checkLibrary() {
// Backend now populates `inLibrary` directly on each MBReleaseGroup
// via the local_release_group_id cross-reference column. Just read it.
let updated = false;
for (const rg of this.releaseGroups) {
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
this.libraryMBIDs.add(rg.mbid);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
/* ── Playback ── */ /* ── Playback ── */
/** /**
@@ -2015,11 +2015,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
/** /**
* File path for one top track, resolved by recording MBID the * File path for one top track, resolved by recording MBID the
* same key `inLibrary`/`localId` were set from. Works whether or * same key `localId` was set from. Works whether or not the
* not the containing release itself matched a local album. * containing release itself matched a local album.
*
* Gated on the same answer the row is drawn from, or a row drawn
* dimmed and `aria-disabled` would still try to play and fail.
*/ */
private async trackFilePath(track: LBTopRecording): Promise<string | null> { private async trackFilePath(track: LBTopRecording): Promise<string | null> {
if (!(track.inLibrary || track.localId) || !track.recordingMbid) return null; if (!isOwned(track) || !track.recordingMbid) return null;
const libraryID = libraryStore.getSelectedLibraryId() ?? 0; const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await dictByName( const byMBID = await dictByName(
@@ -2063,7 +2066,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
} }
private isTrackOwned(track: LBTopRecording): boolean { private isTrackOwned(track: LBTopRecording): boolean {
return Boolean(track.inLibrary || track.localId); return isOwned(track);
} }
private onTrackRowDblClick(track: LBTopRecording): void { private onTrackRowDblClick(track: LBTopRecording): void {
@@ -2106,7 +2109,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
mbid: rg.releaseGroupMbid || '', mbid: rg.releaseGroupMbid || '',
localId: rg.localId ?? 0, localId: rg.localId ?? 0,
title: rg.title, title: rg.title,
owned: Boolean(rg.inLibrary || rg.localId), owned: isOwned(rg),
}; };
} }
@@ -2126,10 +2129,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
mbid: isLocal ? '' : rg.mbid || '', mbid: isLocal ? '' : rg.mbid || '',
localId: Number.isFinite(localId) ? localId : 0, localId: Number.isFinite(localId) ? localId : 0,
title: rg.title, title: rg.title,
owned: // The same answer the menu gates Play on, which is the
this.libraryMBIDs.has(rg.mbid) || // point: this used to be `inLibrary` too, so a card could
Boolean(rg.inLibrary) || // report itself owned, be offered no Play (that item is
localId > 0, // gated on the local id) and be offered no request either
// (that one is gated on *not* owned).
owned: localId > 0,
}; };
} }
@@ -2944,15 +2949,20 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="top-section-col top-section-col-tracks"> <div class="top-section-col top-section-col-tracks">
<h3 class="section-header">Top Tracks</h3> <h3 class="section-header">Top Tracks</h3>
<div class="track-list"> <div class="track-list">
${tracks.map( ${tracks.map((t, i) => {
(t, i) => html` const owned = this.isTrackOwned(t);
return html`
<div <div
class=${classMap({ 'track-item': true, owned: this.isTrackOwned(t) })} class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
tabindex="0" tabindex="0"
role="button" role="button"
aria-label=${this.isTrackOwned(t) aria-disabled=${owned ? 'false' : 'true'}
? `Play “${t.trackName}` aria-label=${ownershipLabel(owned, 'Play', t.trackName, 'track')}
: `${t.trackName} — not in your library`}
@dblclick=${() => this.onTrackRowDblClick(t)} @dblclick=${() => this.onTrackRowDblClick(t)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)} @contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)} @keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
@@ -2977,16 +2987,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<span class="track-listens"> <span class="track-listens">
${formatListenCount(t.totalListenCount)} plays ${formatListenCount(t.totalListenCount)} plays
</span> </span>
<library-status-indicator ${owned
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)} ? nothing
entity-type="track" : html`<library-status-indicator
label=${t.trackName} status=${libraryStatusFor(false, t.recordingMbid)}
request-mbid=${t.recordingMbid} entity-type="track"
request-artist=${t.artistName ?? ''} label=${t.trackName}
></library-status-indicator> request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>`}
</div> </div>
`, `;
)} })}
</div> </div>
${canExpandTracks ${canExpandTracks
? html` ? html`
@@ -3057,10 +3069,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private renderTopReleaseCard(rg: LBTopReleaseGroup) { private renderTopReleaseCard(rg: LBTopReleaseGroup) {
const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || ''; const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || '';
const target = this.topReleaseTarget(rg); const target = this.topReleaseTarget(rg);
const owned = target.owned;
const badge = albumBadgeFor(
{ localId: target.localId },
rg.releaseGroupMbid,
);
return html` return html`
<div <div
class="top-release-card" class=${classMap({ 'top-release-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToTopRelease(rg)} @click=${() => this.navigateToTopRelease(rg)}
role="button" role="button"
tabindex="0" tabindex="0"
@@ -3092,14 +3110,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="top-release-meta-text"> <div class="top-release-meta-text">
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing} ${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div> </div>
<library-status-indicator ${badge.status === 'in-library'
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)} ? nothing
entity-type="album" : html`<library-status-indicator
label=${rg.title} status=${badge.status}
request-mbid=${rg.releaseGroupMbid} owned=${badge.owned}
request-artist=${this.artist?.name ?? ''} expected=${badge.expected}
size="18" entity-type="album"
></library-status-indicator> label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>`}
</div> </div>
</div> </div>
</div> </div>
@@ -3189,14 +3211,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private renderAlbumCard(rg: MBReleaseGroup) { private renderAlbumCard(rg: MBReleaseGroup) {
const artURL = this.thumbnailURLs.get(rg.mbid) || ''; const artURL = this.thumbnailURLs.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate); const year = extractYear(rg.firstReleaseDate);
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
const status = libraryStatusFor(inLibrary, rg.mbid);
const target = this.albumTarget(rg); const target = this.albumTarget(rg);
const owned = target.owned;
const badge = albumBadgeFor({ localId: target.localId }, target.mbid);
return html` return html`
<div <div
class="album-card" class=${classMap({ 'album-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)} @click=${() => this.navigateToAlbum(rg)}
role="button" role="button"
tabindex="0" tabindex="0"
@@ -3225,13 +3248,17 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="album-meta-text"> <div class="album-meta-text">
${year ? html`<span>${year}</span>` : nothing} ${year ? html`<span>${year}</span>` : nothing}
</div> </div>
<library-status-indicator ${badge.status === 'in-library'
status=${status} ? nothing
entity-type="album" : html`<library-status-indicator
label=${rg.title} status=${badge.status}
request-mbid=${rg.mbid} owned=${badge.owned}
request-artist=${this.artist?.name ?? ''} expected=${badge.expected}
></library-status-indicator> entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>`}
</div> </div>
</div> </div>
`; `;
@@ -1,5 +1,11 @@
import { avatarBackground } from '@utils/avatar-color'; import { avatarBackground } from '@utils/avatar-color';
import { libraryStatusFor } from '@utils/library-status'; import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store';
import { downloadStore } from '@store/download-store'; import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js'; import { customElement, state, query as litQuery } from 'lit/decorators.js';
@@ -171,7 +177,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
private searchDebounceTimer?: ReturnType<typeof setTimeout>; private searchDebounceTimer?: ReturnType<typeof setTimeout>;
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT); private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT); private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
private libraryMBIDs = new Set<string>();
constructor() { constructor() {
super(); super();
@@ -226,6 +231,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
srOnly, srOnly,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
unownedStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -812,6 +818,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
// Explore should not pay for it. // Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate())); this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate()); void downloadStore.init().then(() => this.requestUpdate());
// How much of an owned album is here arrives a frame after the
// cards do — the store coalesces a screenful into one query —
// so a card that turns out to be 9 of 12 repaints when the
// answer lands rather than showing a plain tick until something
// else happens to re-render the grid.
this.whileActive(completenessStore.subscribe(() => this.requestUpdate()));
} }
/** A debounced search that lands after the user has left the page is /** A debounced search that lands after the user has left the page is
@@ -1083,7 +1096,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
this.results?.artists ?? [], this.results?.artists ?? [],
this.results?.releaseGroups ?? [], this.results?.releaseGroups ?? [],
); );
this.checkLibrary();
} catch (err) { } catch (err) {
if (version !== this.searchVersion) return; if (version !== this.searchVersion) return;
@@ -1229,8 +1241,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
void this.playAlbum(rg, false); void this.playAlbum(rg, false);
} }
private onRecordingRowDblClick(r: { mbid: string; inLibrary: boolean; localId?: number }): void { // The same answer the row is drawn from. It used to accept
if (!r.inLibrary && !r.localId) return; // `inLibrary` as well, so a row drawn dimmed and `aria-disabled`
// would still try to play and fail with a notification.
private onRecordingRowDblClick(r: { mbid: string; localId?: number }): void {
if (!isOwned(r)) return;
void this.playRecording(r.mbid); void this.playRecording(r.mbid);
} }
@@ -1665,42 +1680,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
} }
} }
/**
* Check which result MBIDs exist in the local library.
*/
private checkLibrary() {
if (!this.results) return;
// Backend now populates `inLibrary` directly on each MB result
// via the local_*_id cross-reference columns. Just read those.
let updated = false;
for (const a of this.results.artists ?? []) {
if (a.mbid && a.inLibrary && !this.libraryMBIDs.has(a.mbid)) {
this.libraryMBIDs.add(a.mbid);
updated = true;
}
}
for (const rg of this.results.releaseGroups ?? []) {
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
this.libraryMBIDs.add(rg.mbid);
updated = true;
}
}
for (const r of this.results.recordings ?? []) {
if (r.mbid && r.inLibrary && !this.libraryMBIDs.has(r.mbid)) {
this.libraryMBIDs.add(r.mbid);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
/* ── Navigation ── */ /* ── Navigation ── */
private navigateToArtist(artist: MBArtist) { private navigateToArtist(artist: MBArtist) {
@@ -2107,12 +2086,16 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing} : nothing}
<div class="horizontal-row"> <div class="horizontal-row">
${artists.map((a) => { ${artists.map((a) => {
const owned = isOwned(a);
const name = a.englishName || a.name;
return html` return html`
<div <div
class="artist-card" class=${classMap({ 'artist-card': true, unowned: !owned })}
@click=${() => this.navigateToArtist(a)} @click=${() => this.navigateToArtist(a)}
role="button" role="button"
tabindex="0" tabindex="0"
aria-label=${ownershipLabel(owned, 'Artist', name, 'artist')}
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
@@ -2171,11 +2154,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const artURL = this.thumbnailCache.get(rg.mbid) || ''; const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate); const year = extractYear(rg.firstReleaseDate);
const owned = Boolean(rg.localId); const owned = isOwned(rg);
const badge = albumBadgeFor(rg, rg.mbid);
return html` return html`
<div <div
class=${classMap({ 'album-card': true, owned })} class=${classMap({
'album-card': true,
owned,
unowned: !owned,
})}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)} @click=${() => this.navigateToAlbum(rg)}
@dblclick=${() => this.onAlbumCardDblClick(rg)} @dblclick=${() => this.onAlbumCardDblClick(rg)}
@contextmenu=${(e: MouseEvent) => @contextmenu=${(e: MouseEvent) =>
@@ -2228,13 +2217,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing} : nothing}
${year ? html`<span>${year}</span>` : nothing} ${year ? html`<span>${year}</span>` : nothing}
</div> </div>
<library-status-indicator ${badge.status === 'in-library'
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)} ? nothing
entity-type="album" : html`<library-status-indicator
label=${rg.title} status=${badge.status}
request-mbid=${rg.mbid} owned=${badge.owned}
request-artist=${rg.artistCredit ?? ''} expected=${badge.expected}
></library-status-indicator> entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>`}
</div> </div>
</div> </div>
`; `;
@@ -2249,12 +2242,20 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<section> <section>
<h3 class="section-header">Tracks</h3> <h3 class="section-header">Tracks</h3>
<div class="track-list"> <div class="track-list">
${recordings.map( ${recordings.map((r) => {
(r) => html` const owned = isOwned(r);
return html`
<div <div
class=${classMap({ 'track-item': true, owned: Boolean(r.inLibrary || r.localId) })} class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
role="button" role="button"
tabindex="0" tabindex="0"
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${ownershipLabel(owned, 'Play', r.title, 'track')}
@dblclick=${() => this.onRecordingRowDblClick(r)} @dblclick=${() => this.onRecordingRowDblClick(r)}
@contextmenu=${(e: MouseEvent) => @contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, { this.onExploreContextMenu(e, {
@@ -2291,16 +2292,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
? html`<span class="track-duration">${formatDuration(r.length)}</span>` ? html`<span class="track-duration">${formatDuration(r.length)}</span>`
: nothing} : nothing}
</div> </div>
<library-status-indicator ${owned
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)} ? nothing
entity-type="track" : html`<library-status-indicator
label=${r.title} status=${libraryStatusFor(false, r.mbid)}
request-mbid=${r.mbid} entity-type="track"
request-artist=${r.artistCredit ?? ''} label=${r.title}
></library-status-indicator> request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>`}
</div> </div>
`, `;
)} })}
</div> </div>
</section> </section>
`; `;
+63 -38
View File
@@ -1,8 +1,9 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js'; import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/service.js'; import { GetShelves } from '@go/home/service.js';
import { ICON_SHUFFLE } from '@utils/icon-language';
import type { PageAction } from '@components/page-header/page-header';
import { GetAlbumTracks } from '@go/library/library.js'; import { GetAlbumTracks } from '@go/library/library.js';
import type * as home from '@go/home/models.js'; import type * as home from '@go/home/models.js';
import type * as library from '@go/library/models.js'; import type * as library from '@go/library/models.js';
@@ -160,29 +161,52 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
user-select: none; user-select: none;
} }
/*
* The hover play button is a *hover* affordance, so it is
* 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
* 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.
*
* display:none outside the query rather than opacity:0 on
* its own: an opacity-0 button still takes taps and is
* still in the accessibility tree, so the invisible control
* would keep the hit area it was never meant to have on
* touch. Everything else stays inside, so the desktop
* animation is unchanged.
*/
.play { .play {
position: absolute; display: none;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
} }
.card:hover .play, @media (hover: hover) and (pointer: fine) {
.card:focus-within .play { .play {
opacity: 1; position: absolute;
transform: translateY(0); right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: var(--yj-accent-fg, #000);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
} }
.name { .name {
@@ -260,23 +284,24 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
override render() { override render() {
return html` return html`
<page-header heading="Home"> <page-header
<!-- "Shuffle" alone was two different controls with one heading="Home"
name: this one and the transport's shuffle mode. .actions=${[
They were never on screen together until the app {
started landing on Home (H-8), and a cached view is // "Shuffle" alone was two different controls
in the accessibility tree either way. --> // with one name: this one and the transport's
<wa-button // shuffle mode. They were never on screen
slot="actions" // together until the app started landing on
size="small" // Home (H-8), and a cached view is in the
appearance="plain" // accessibility tree either way.
title="Reshuffle the suggestions" id: 'shuffle-suggestions',
@click=${() => void this.load()} label: 'Shuffle suggestions',
> icon: ICON_SHUFFLE,
<wa-icon slot="start" name="shuffle"></wa-icon> title: 'Reshuffle the suggestions',
Shuffle suggestions onSelect: () => void this.load(),
</wa-button> },
</page-header> ] satisfies PageAction[]}
></page-header>
<p class="lede">Somewhere to start listening.</p> <p class="lede">Somewhere to start listening.</p>
${this.renderBody()} ${this.renderBody()}
`; `;
+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;
}
}
+15 -1
View File
@@ -155,13 +155,27 @@ export class JobIndicator extends LitElement {
goes -- the live region in render() is what announces goes -- the live region in render() is what announces
this, and it is unaffected, so the ring keeps its this, and it is unaffected, so the ring keeps its
accessible name and screen readers keep hearing the accessible name and screen readers keep hearing the
state change. */ state change.
[compact] is the same removal asked for by measurement
rather than by width, and it is set from outside: the
shell's fit pass (services/top-bar-fit.ts, #143) owns
it, because between 600 and 900 whether this label fits
depends on what else is in the bar and on how long the
running job's title is -- 235px for "Scanning Music from
the external drive" -- rather than on the viewport. Two
triggers, one effect, and the phone's is unconditional
because it was argued and pinned before this existed. */
@media (max-width: 599px) { @media (max-width: 599px) {
.label { .label {
display: none; display: none;
} }
} }
:host([compact]) .label {
display: none;
}
.alert-dot { .alert-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
+267
View File
@@ -0,0 +1,267 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job, JobKind } from '@store/job-store';
import { isTerminal } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
/**
* The background work of one kind, rendered wherever that work is
* started or configured.
*
* #27 folded the Jobs tab away, and the shape it folded into is this
* rather than one "Background jobs" panel in Settings which would
* have been the tab again under another name. Reading the app first
* turned up that **four of the five job kinds already had a home**
* showing their work: Settings Search Index draws per-tier index
* progress, `downloads-view` draws every download's lifecycle state,
* `autotag-view` draws its own apply ring, and only `library-scan` had
* nowhere but the tab. What none of those four had is the *generic*
* affordances pause, cancel, "Details", the log, and a finished job
* you can dismiss which is what this carries to each of them.
*
* Three things about it are load-bearing.
*
* **The controls are `applyJobControl`, not a reimplementation.** That
* is what keeps the "you will discard hours of downloading"
* confirmation on an index build alive across the move: it is keyed on
* `KindIndexBuild` inside the shared handler, and a host that rendered
* its own buttons would silently drop it.
*
* **A panel with nothing to say renders nothing at all**, host padding
* included an idle panel in four places is four pieces of furniture
* describing an absence. That is the rule `startBackfillJob` follows
* for the indicator, one layer up.
*
* **There is no "Clear finished" here**, because `ClearFinishedJobs` is
* global: a Clear in the Libraries panel would silently discard the
* index build's history too. A finished row dismisses itself, which is
* per-job and is what `job-row` already offers.
*/
@customElement('job-panel')
export class JobPanel extends LitElement {
/**
* Comma-separated job kinds, e.g. `index-build,catalog-enrich`.
*
* An attribute rather than a property because every call site is a
* 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 = '';
/** Heading above the rows. Omitted renders no heading. */
@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[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
margin-top: 1em;
}
/* An empty panel takes no room at all, margin included. */
:host([hidden]) {
display: none;
}
h3 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 0.5em;
}
.card {
background: var(--yj-bg-surface, #2b3035);
border: 1px solid var(--yj-border, #495057);
border-radius: 6px;
overflow: hidden;
}
.job-entry {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.6em 0.8em;
border-bottom: 1px solid var(--yj-border-subtle, #3a4046);
}
.job-entry:last-child {
border-bottom: none;
}
job-row {
flex: 1;
/* A grid child's implicit minimum is its content, and a
job title is long. */
min-width: 0;
}
.details-btn {
background: none;
border: 1px solid var(--yj-border, #495057);
border-radius: 4px;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-family: inherit;
font-size: var(--yj-text-sm);
padding: 0.3em 0.6em;
white-space: nowrap;
}
.details-btn:hover {
color: var(--yj-text-primary, #e9ecef);
}
`,
];
override connectedCallback() {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
}
/** The kinds this panel answers for. */
private get wanted(): ReadonlySet<string> {
return new Set(
this.kinds
.split(',')
.map((k) => k.trim())
.filter(Boolean),
);
}
private get mine(): Job[] {
const ofKind =
this.kinds.trim() === '*'
? this.jobs
: this.jobs.filter((job) =>
this.wanted.has(job.kind as JobKind),
);
return this.activeOnly ? ofKind.filter((job) => !isTerminal(job)) : ofKind;
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
override render() {
const mine = this.mine;
// Hidden rather than empty: see the class comment. The drawer
// goes with it, since it can only have been opened from a row.
this.hidden = mine.length === 0;
if (mine.length === 0) return nothing;
const active = mine.filter((job) => !isTerminal(job));
const finished = mine.filter(isTerminal);
return html`
${this.heading ? html`<h3>${this.heading}</h3>` : nothing}
<div class="card">
${[...active, ...finished].map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant=${this.density}
@job-control=${applyJobControl}
></job-row>
<button
type="button"
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-panel': JobPanel;
}
}
-568
View File
@@ -1,568 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import {
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js';
import type * as library from '@go/library/models.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
type LibraryInfo = library.Info;
/** Job states meaning the job will not progress further. */
const TERMINAL_STATES: ReadonlySet<string> = new Set([
'complete',
'cancelled',
'error',
]);
/**
* Full-page view of background work: everything running right now, the
* per-library scan controls that used to live in Settings, and a short
* history of what recently finished.
*
* This is the same job rows as the top-bar popover at a larger density
* one implementation, two placements, so the two can never disagree.
*/
@customElement('jobs-view')
export class JobsView extends ViewLifecycleMixin(LitElement) {
@state()
private jobs: Job[] = [];
@state()
private libraries: LibraryInfo[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
/**
* Set between pressing a scan button and the job snapshot that
* proves it started. `anyScanning` is derived from `JobsChanged`,
* which is coalesced at 250 ms long enough for a second click to
* start a second scan (errors.M5).
*/
@state()
private starting = false;
private unsubscribe: (() => void) | null = null;
private eventCleanups: Array<() => void> = [];
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
overflow-y: auto;
height: 100%;
padding: 1.5em 1.75em 3em;
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -1.5em -1.75em 1em;
}
h1 {
font-size: var(--yj-text-xl);
color: var(--yj-text-primary, #e9ecef);
margin: 0 0 0.2em;
}
.page-sub {
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 1.75em;
}
section {
margin-bottom: 2em;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.75em;
}
h2 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0;
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
overflow: hidden;
}
.card > * + * {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.job-entry {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5em;
padding-right: 0.75em;
}
.empty {
padding: 1.1em;
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
.library-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1em;
padding: 0.75em 0.9em;
}
.library-name {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
}
.library-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
margin-top: 0.15em;
overflow-wrap: anywhere;
}
.library-state {
font-size: var(--yj-text-sm);
color: var(--job-tone);
margin-top: 0.15em;
}
button.action {
display: inline-flex;
align-items: center;
gap: 0.45em;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-primary, #e9ecef);
font-size: var(--yj-text-sm);
padding: 0.42em 0.85em;
cursor: pointer;
white-space: nowrap;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button.action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.11);
}
button.action:disabled {
opacity: 0.4;
cursor: default;
}
button.action:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button.action.danger {
color: var(--yj-error-text, #ff8787);
border-color: rgba(255, 107, 107, 0.35);
}
button.action.danger:hover:not(:disabled) {
background: rgba(255, 107, 107, 0.12);
}
button.link {
border: none;
background: transparent;
color: var(--yj-accent-text, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.2em 0.4em;
border-radius: 5px;
}
button.link:hover {
text-decoration: underline;
}
.details-btn {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.3em 0.5em;
border-radius: 6px;
white-space: nowrap;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
`,
];
protected override onViewActivate(): void {
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
void jobStore.init();
this.jobs = jobStore.jobs;
void this.loadLibraries();
// Library CRUD happens elsewhere; keep the picker in step.
for (const event of [
Events.LibraryAdded,
Events.LibraryRemoved,
Events.LibraryRenamed,
Events.LibraryScanComplete,
]) {
this.eventCleanups.push(
EventsOn(event, () => void this.loadLibraries()),
);
}
}
protected override onViewDeactivate(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.eventCleanups.forEach((off) => off());
this.eventCleanups = [];
}
private async loadLibraries(): Promise<void> {
try {
this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? [];
} catch (err) {
console.error('Failed to load libraries:', err);
}
}
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return jobStore.getJob(`scan:${id}`);
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.starting) return;
this.starting = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.starting = false;
}
}
private async startScan(id: number) {
await this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => void this.startScan(id),
);
}
private async startAllScans() {
await this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => void this.startAllScans(),
);
}
private async clearFinished() {
await jobStore.clearFinished();
}
private async fullRescan() {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded ' +
'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up ' +
'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.fullRescan(),
);
}
private renderJobList(list: Job[], emptyText: string) {
if (list.length === 0) {
return html`<div class="card">
<div class="empty">${emptyText}</div>
</div>`;
}
return html`
<div class="card">
${list.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
<button
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
/** The status line under a library name in the scan-control list. */
private libraryStatus(job: Job | undefined): string | null {
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private renderLibraryRow(lib: LibraryInfo) {
const job = this.jobForLibrary(lib.id);
const status = this.libraryStatus(job);
const busy = status !== null;
return html`
<div class="library-row">
<div>
<div class="library-name">${lib.name}</div>
<div class="library-meta">
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
</div>
${status
? html`<div class="library-state">${status}</div>`
: nothing}
</div>
${busy
? html`
<button
class="link"
@click=${() => this.openDetails(`scan:${lib.id}`)}
>
View progress
</button>
`
: html`
<button
class="action"
?disabled=${this.starting}
@click=${() => this.startScan(lib.id)}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan now
</button>
`}
</div>
`;
}
override render() {
// Derived from `this.jobs` rather than the store getters so Lit
// sees the reactive dependency and re-renders on every snapshot.
const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state));
const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state));
const anyScanning = this.libraries.some((lib) =>
Boolean(this.libraryStatus(this.jobForLibrary(lib.id))),
);
return html`
<page-header heading="Background jobs"></page-header>
<p class="page-sub">
Library scans and search index builds, with their progress and
output.
</p>
<section>
<div class="section-head">
<h2>Running now</h2>
</div>
${this.renderJobList(active, 'Nothing is running.')}
</section>
<section>
<div class="section-head">
<h2>Libraries</h2>
<button
class="action"
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.startAllScans}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan all
</button>
</div>
<div class="card">
${this.libraries.length === 0
? html`<div class="empty">
No libraries yet add one in Settings.
</div>`
: this.libraries.map((lib) =>
this.renderLibraryRow(lib),
)}
</div>
</section>
<section>
<div class="section-head">
<h2>Maintenance</h2>
</div>
<div class="card">
<div class="library-row">
<div>
<div class="library-name">Full rescan</div>
<div class="library-meta">
Wipes all library data and cover art, then
rebuilds from your files. Only needed when the
library is corrupt a normal scan already
picks up changes.
</div>
</div>
<button
class="action danger"
?disabled=${anyScanning ||
this.starting ||
this.libraries.length === 0}
@click=${this.fullRescan}
>
<wa-icon name="triangle-exclamation"></wa-icon>
Full rescan
</button>
</div>
</div>
</section>
${finished.length > 0
? html`
<section>
<div class="section-head">
<h2>Recently finished</h2>
<button
class="link"
@click=${this.clearFinished}
>
Clear
</button>
</div>
${this.renderJobList(finished, '')}
</section>
`
: nothing}
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'jobs-view': JobsView;
}
}
@@ -297,9 +297,23 @@ export class LibraryStatusIndicator extends LitElement {
// row to one and nothing to the other, and "Add … to library" // row to one and nothing to the other, and "Add … to library"
// was the old button's promise written into the copy. // was the old button's promise written into the copy.
if (this.actionable) { if (this.actionable) {
return this.status === 'queued' if (this.status === 'queued') {
? `Cancel the request for ${kind}${name}` return `Cancel the request for ${kind}${name}`;
: `Request ${kind}${name}`; }
// A partly-held album is actionable *and* has a count, and
// the count does not survive being named after the action
// alone. The `partial` case below says why it matters — a
// ring says "some" to a sighted user and nothing to anyone
// else — and that argument does not stop applying because
// the badge became clickable. This branch used to say only
// "Request album X", so the one state the ring exists for
// was the one state whose name did not mention it.
if (this.status === 'partial') {
return `Request the rest of ${kind}${name}${this.owned} of ${this.expected} tracks are in your library`;
}
return `Request ${kind}${name}`;
} }
switch (this.status) { switch (this.status) {
@@ -0,0 +1,133 @@
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { HistoryController } from '@store/controllers/history-controller';
/**
* Global back and forward, in the top bar (#6).
*
* **The stack was already global; the affordance was not.** Every
* navigation has been a history entry since the Android back gesture
* landed, and `popstate` restores any of them in either direction --
* `back-navigation.spec.ts` has asserted `goForward()` since it was
* written. What the report describes as "back is tab-scoped" is that
* the *only* way back was a detail view's own button, which vanishes
* the moment you leave for another tab: the album you were reading is
* still one entry away, and nothing on screen says so or offers it.
*
* Four things about this are load-bearing.
*
* **It asks the shell rather than the History API.** `history.length`
* counts entries this app did not push and never shrinks, and there is
* no way to ask where in the list you are -- so a control derived from
* it is confidently wrong at both ends. `historyStore` is the shell's
* own numbering.
*
* **A control that cannot act is `disabled`, not hidden.** This is the
* one place in the app where that is right rather than the fault
* `library-status-indicator` was: back and forward are a *pair* whose
* positions the user learns, and a button that disappears at the end
* of the list moves the other one under the cursor. It is also what
* every browser does, which is the whole design brief here.
*
* **The buttons dispatch the events the rest of the app already
* dispatches**, `navigate-back` and `navigate-forward`, rather than
* calling `history.back()` themselves. The shell owns the guard -- one
* press is one entry, and at the root there is nothing of ours to go
* back to -- and a second caller reaching for `history` directly is
* how the old `navStack` came to disagree with the platform.
*
* **It is desktop chrome.** Below 600px the phone has a system back
* gesture (and, on Android, a hardware/gesture Back that this app
* hooks), the top bar is 3.25em with three other things in it, and two
* more 32px targets there would be the first thing to overflow. Hidden
* by `index.css` at that width, next to the rest of the phone header's
* concessions.
*/
@customElement('nav-history')
export class NavHistory extends LitElement {
private historyCtrl = new HistoryController(this);
static override styles = [designTokens, css`
:host {
display: flex;
align-items: center;
gap: 0.25em;
/* A grid item's implicit minimum is its content; this one
genuinely cannot shrink, so it says so rather than
letting the header widen the body. */
flex: 0 0 auto;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--yj-text-primary, #f8f9fa);
cursor: pointer;
font-size: 1em;
}
button:hover:not(:disabled) {
background-color: var(--yj-bg-overlay, #495057);
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button:disabled {
/* Not a contrast failure: a disabled control is exempt from
1.4.3, and the pair has to read as unavailable rather
than merely quiet. */
color: var(--yj-text-tertiary, #868e96);
cursor: default;
}
`];
private go(direction: 'back' | 'forward') {
this.dispatchEvent(new CustomEvent(`navigate-${direction}`, {
bubbles: true,
composed: true,
}));
}
override render() {
const { canBack, canForward } = this.historyCtrl.depth;
return html`
<button
type="button"
data-testid="history-back"
aria-label="Back"
?disabled=${!canBack}
@click=${() => this.go('back')}
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<button
type="button"
data-testid="history-forward"
aria-label="Forward"
?disabled=${!canForward}
@click=${() => this.go('forward')}
>
<wa-icon name="arrow-right"></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'nav-history': NavHistory;
}
}
@@ -15,6 +15,7 @@ import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css'; import { srOnly } from '../../styles/sr-only.css';
import { ICON_QUEUE } from '@utils/icon-language'; 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, * What is playing, at the size a phone has room for (plan 016 B2,
@@ -116,8 +117,27 @@ export class NowPlayingView extends LitElement {
.art .placeholder { .art .placeholder {
/* Square, and never taller than the room left over: the /* Square, and never taller than the room left over: the
art is the one thing here that would happily push the art is the one thing here that would happily push the
transport off the bottom of a short phone. */ transport off the bottom of a short phone.
**max-height is what actually keeps that promise**, and
it was missing. With a definite width and
a 1:1 aspect-ratio the height is *derived from the width*
and is bounded by nothing: at the reference device's
424x439 that is a 263px square (60vh) in a box with far
less than 263px left, so the art overflowed its own
centred flex item and drew over the header above and the
title below it. The comment claimed this was handled;
60vh is a bound on the *viewport*, not on the room left
over, and those differ by however much chrome is above
and below.
Pre-existing -- screenshotted on main -- and made acute
by #56, which gives the transport 95px more than it had.
Found by reading a screenshot, which is the only tier
that can see it: nothing fails, nothing overflows the
*shell*, and every control is still hittable. */
width: min(100%, 60vh); width: min(100%, 60vh);
max-height: 100%;
aspect-ratio: 1; aspect-ratio: 1;
object-fit: cover; object-fit: cover;
border-radius: 12px; border-radius: 12px;
@@ -226,13 +246,15 @@ export class NowPlayingView extends LitElement {
* *
* This view hides the bottom bar (index.css), and the bar is where * This view hides the bottom bar (index.css), and the bar is where
* the queue button lives -- so without this, going full-screen * 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 * `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 * 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() { private openQueue() {
document.getElementById('queue-panel')?.setAttribute('open', ''); showQueue();
} }
private toggleFavorite() { private toggleFavorite() {
@@ -314,7 +336,19 @@ export class NowPlayingView extends LitElement {
<div class="transport"> <div class="transport">
<seek-bar></seek-bar> <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> <volume-control></volume-control>
</div> </div>
`; `;
@@ -13,6 +13,7 @@ import {
isQueueSourceNavigable, isQueueSourceNavigable,
navigateToQueueSource, navigateToQueueSource,
} from '@utils/queue-source-link'; } from '@utils/queue-source-link';
import { PHONE_QUERY } from '@utils/breakpoints';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { creditStore } from '@store/credit-store'; import { creditStore } from '@store/credit-store';
import { QueueController } from '@store/controllers/queue-controller'; import { QueueController } from '@store/controllers/queue-controller';
@@ -80,6 +81,19 @@ export class NowPlaying extends LitElement {
private reduceMotionQuery?: MediaQueryList; private reduceMotionQuery?: MediaQueryList;
/**
* Phone width, from the shell's own breakpoint.
*
* This is in JS rather than in the stylesheet because what changes
* is the *content*, not its appearance: the title, artist and
* source render as plain text instead of as links, and no CSS rule
* can take a click handler off an element.
*/
@state()
private phone = false;
private phoneQuery?: MediaQueryList;
/** Whether each field is actively mid-scroll (class toggle). */ /** Whether each field is actively mid-scroll (class toggle). */
@state() @state()
private titleScrolling = false; private titleScrolling = false;
@@ -174,12 +188,44 @@ export class NowPlaying extends LitElement {
cursor: pointer; cursor: pointer;
/* The art shows through; this is a target, not a picture. */ /* The art shows through; this is a target, not a picture. */
color: transparent; color: transparent;
/* **Above the art, or it is not a target at all** (#150).
This button is absolutely positioned with z-index auto and
the art is a *later* sibling, so the two tie on paint order
and the later one wins. With an <img> that costs nothing --
an image is not a hit-test obstacle here -- but a track with
no artwork renders a placeholder wa-icon, which is, and it
takes every click aimed at the button underneath it.
The failure is therefore per *track*, not per build: on a
phone the only way into the full-screen now-playing view
stopped working whenever the current song had no cover.
Measured with elementFromPoint at the button's centre --
wa-icon with a placeholder, button.expand with an image, and
button.expand either way once this line exists.
z-index rather than pointer-events: none on the art, which
would take the cover preview's mouseenter with it; and
rather than reordering the DOM, which would leave the same
tie to be won by the same accident in the other direction. */
z-index: 1;
} }
.expand:focus-visible { .expand:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b); outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px; 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 { .cover-preview-panel {
@@ -341,6 +387,12 @@ export class NowPlaying extends LitElement {
this.reduceMotion = this.reduceMotionQuery?.matches ?? false; this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange); this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange);
// Same reasoning as above: looked up here, not at module load,
// so a test can install its own matchMedia first.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.phone = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.handlePhoneChange);
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
this.geometryDirty = true; this.geometryDirty = true;
this.requestUpdate(); this.requestUpdate();
@@ -364,6 +416,7 @@ export class NowPlaying extends LitElement {
this.attachDragListeners(false); this.attachDragListeners(false);
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent); window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange); this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
this.phoneQuery?.removeEventListener('change', this.handlePhoneChange);
this.resizeObserver?.disconnect(); this.resizeObserver?.disconnect();
this.stopScrollCycle('title'); this.stopScrollCycle('title');
this.stopScrollCycle('artist'); this.stopScrollCycle('artist');
@@ -404,8 +457,30 @@ export class NowPlaying extends LitElement {
return html` return html`
<div class="sr-only" role="status" aria-live="polite">${announcement}</div> <div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing"> <div class="now-playing">
<div class="cover-art"> <!-- **The way to Now Playing does not depend on what is
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div> 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> </div>
<div <div
@@ -488,7 +563,7 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleTitleMouseLeave} @mouseleave=${this.handleTitleMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('title')} @transitionend=${() => this.onScrollCycleEnd('title')}
> >
<span class="scroll-content">${trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span> <span class="scroll-content">${this.phone ? track.title : trackLink(track.title, track.album, track.releaseGroupMbid, track.recordingMbid) || track.title}</span>
</span> </span>
<span <span
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}" class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
@@ -498,14 +573,15 @@ export class NowPlaying extends LitElement {
@mouseleave=${this.handleArtistMouseLeave} @mouseleave=${this.handleArtistMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('artist')} @transitionend=${() => this.onScrollCycleEnd('artist')}
> >
<span class="scroll-content">${creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span> <span class="scroll-content">${this.phone ? track.artist || 'Unknown Artist' : creditLink(creditStore.credits(track.recordingMbid), track.artist, track.artistMbid) || 'Unknown Artist'}</span>
</span> </span>
${describeQueueSource(this.queue.source) ${describeQueueSource(this.queue.source)
? html` ? html`
<span <span
class="track-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}" class="track-source ${!this.phone && isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
data-testid="now-playing-source" data-testid="now-playing-source"
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
if (this.phone) return;
if (!isQueueSourceNavigable(this.queue.source)) return; if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource( navigateToQueueSource(
e.currentTarget as EventTarget, e.currentTarget as EventTarget,
@@ -571,6 +647,10 @@ export class NowPlaying extends LitElement {
this.reduceMotion = e.matches; this.reduceMotion = e.matches;
}; };
private handlePhoneChange = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private shouldScroll(field: 'title' | 'artist'): boolean { private shouldScroll(field: 'title' | 'artist'): boolean {
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows; const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
@@ -606,6 +686,12 @@ export class NowPlaying extends LitElement {
track?.artist ?? '', track?.artist ?? '',
this.shouldScroll('title') ? '1' : '0', this.shouldScroll('title') ? '1' : '0',
this.shouldScroll('artist') ? '1' : '0', this.shouldScroll('artist') ? '1' : '0',
// Crossing the breakpoint swaps a link for a bare string,
// and a link is not guaranteed to measure the same as the
// text inside it. The marquee travels a distance read from
// that measurement, so this belongs in the key even though
// the words are identical either side.
this.phone ? '1' : '0',
].join('\u0000'); ].join('\u0000');
} }
@@ -1,8 +1,17 @@
import { LitElement, html, css, nothing } from 'lit'; import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.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 { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import {
MenuKeyboard,
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. * The one arrangement every primary view uses to say what it is.
@@ -18,6 +27,19 @@ import { designTokens } from '../../styles/tokens.css';
* Title, count, sort, actions in that order, in one component, so a * Title, count, sort, actions in that order, in one component, so a
* new view gets the shape by using it rather than by copying whichever * new view gets the shape by using it rather than by copying whichever
* neighbour it happened to read. * neighbour it happened to read.
*
* **Actions are data, and `<slot name="actions">` is the exception.**
* Playlists' three buttons totalled 390px inside a header that gets
* 700px at 900×600 and clipped "New Smart Playlist" to 114 of its 162
* (#69) a live defect at a size the app promises, against plan 018's
* *no action is ever unreachable at any supported size*. The header
* cannot fix that for slotted markup: it cannot move another
* component's light-DOM children into a dropdown and keep their
* behaviour, and arbitrary markup offers nothing generic to render as
* a menu item. So a host declares `PageAction[]` and the header picks
* the rendering. The slot survives for markup a data list genuinely
* cannot express, at the stated cost that **a slotted action does not
* collapse** and must therefore fit at 800×600.
*/ */
export interface SortOption { export interface SortOption {
@@ -27,6 +49,50 @@ export interface SortOption {
export type SortDirection = 'asc' | 'desc'; export type SortDirection = 'asc' | 'desc';
/**
* An action that only makes sense while it is a button.
*
* A drop target is the case: you cannot drag a track onto a closed
* menu, so the affordance is absent from the overflow rather than
* approximated there. The header wires these onto the button it
* renders and owns none of them the same division the sort control
* already lives by.
*/
export interface PageActionDrop {
/** True while an acceptable payload is over the button. */
active?: boolean;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
}
/**
* One thing a view can do, as data rather than as markup.
*
* `<slot name="actions">` cannot be collapsed, and that is a fact about
* the API rather than an effort estimate (#69): a component cannot move
* another component's light-DOM children into a dropdown and keep their
* behaviour, and there is nothing generic in arbitrary markup to render
* as a menu item. Declaring an action instead is what lets the header
* choose between the two renderings.
*/
export interface PageAction {
id: string;
label: string;
/** From `utils/icon-language`, never a literal. */
icon: string;
onSelect: () => void;
/**
* Higher survives longer. The lowest collapses first, ties broken
* by declaration order from the right, so a host that says nothing
* gets "the last one written goes first".
*/
priority?: number;
disabled?: boolean;
title?: string;
drop?: PageActionDrop;
}
@customElement('page-header') @customElement('page-header')
export class PageHeader extends LitElement { export class PageHeader extends LitElement {
/** /**
@@ -80,8 +146,91 @@ export class PageHeader extends LitElement {
@property({ type: Boolean }) @property({ type: Boolean })
busy = false; busy = false;
/**
* What this view can do, in the order it wants them shown.
*
* The header decides what *fits*; the host decides what *happens*.
* That is the rule the sort control already lives by it asks for
* a sort rather than performing one and actions follow it, which
* is why an action carries a handler rather than the header
* carrying a verb it would have to interpret.
*/
@property({ attribute: false })
actions: PageAction[] = [];
/** Action ids currently in the overflow menu. Derived, never set by a host. */
@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;
@query('.page-header')
private headerEl?: HTMLElement;
@query('.more-button')
private moreButton?: HTMLButtonElement;
@query('#page-header-overflow')
private menuPanel?: HTMLElement;
@query('wa-popup')
private popup?: WaPopup;
private menuKeyboard = new MenuKeyboard(() => this.closeMenu());
private resizeObserver?: ResizeObserver;
/**
* Whether the outside-click listener is attached.
*
* A `removeEventListener` with no matching `add` is not harmless
* here: `view-lifecycle.test.ts` counts document listeners across a
* view's life and an unconditional detach on disconnect shows up as
* `held: -1`, which is the same accounting that would hide a real
* leak in the other direction.
*/
private outsideCloseAttached = false;
/**
* What the last fit was measured against.
*
* `updated()` runs on every pass, so it has to say what it depends
* on or it re-measures and a measurement here forces synchronous
* layout. Width changes arrive through the ResizeObserver; this key
* covers everything *else* in the flex row that can change how much
* of it the actions are left.
*/
private lastFitKey = '';
override connectedCallback(): void {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.measureFit());
this.resizeObserver.observe(this);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
this.resizeObserver = undefined;
this.detachOutsideClose();
}
static override styles = [ static override styles = [
designTokens, designTokens,
contextMenuStyles,
css` css`
:host { :host {
display: block; display: block;
@@ -96,12 +245,26 @@ export class PageHeader extends LitElement {
border-bottom: 1px solid var(--yj-border-subtle, #333); border-bottom: 1px solid var(--yj-border-subtle, #333);
} }
/* The title gives way before an action does.
Everything in this row was flex-shrink: 0, so whatever
came last lost and the actions come last, which is how
the "More actions" button ended up 76px off the right
edge of a 320px viewport with every action already
collapsed into it. The title is the one thing here the
navigation also says (the sidebar item is selected, the
bottom-nav tab is current), so it is the cheapest thing
to truncate; the count, the sort and the actions are each
the only place they are said. */
h1 { h1 {
margin: 0; margin: 0;
font-size: var(--yj-text-xl, 18px); font-size: var(--yj-text-xl, 18px);
font-weight: 600; font-weight: 600;
color: var(--yj-text-primary, #fff); color: var(--yj-text-primary, #fff);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
.count { .count {
@@ -191,6 +354,98 @@ export class PageHeader extends LitElement {
::slotted(*) { ::slotted(*) {
flex-shrink: 0; flex-shrink: 0;
} }
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action,
.more-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 6px 12px;
font-size: var(--yj-text-md, 13px);
font-family: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
flex-shrink: 0;
}
.more-button {
padding: 6px 10px;
}
/* The display: flex above outranks the UA stylesheet's
rule for [hidden], and hiding is how an action
collapses. (No backticks in here: one ends the css
literal, and what you get is "css(...) is not a
function" a long way from the cause.) */
.action[hidden],
.more-button[hidden] {
display: none;
}
.action:hover,
.more-button:hover,
.action.drag-over {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
.action.drag-over {
background-color: var(
--yj-accent-bg-strong,
rgba(255, 212, 59, 0.15)
);
}
.action:disabled {
opacity: 0.5;
cursor: default;
}
.action:focus-visible,
.more-button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
wa-popup {
z-index: 200;
}
/* A component states what it drops at phone width itself,
in its own stylesheet, because a media query inside a
shadow root is answered by the viewport and the shell
cannot reach in. Here that is one word: the sort control
is 172px of a 320px header, and "Sort:" is ~40px of it
for a label the adjacent direction arrow already implies.
It stays in the accessibility tree it is the select's
accessible name, so hiding it outright would rename the
control to nothing which is config-field's bug, one
component over. clip-path rather than display: none for
the reason styles/sr-only.css.ts gives. */
@media (max-width: 599px) {
.sort-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
`, `,
]; ];
@@ -212,11 +467,341 @@ export class PageHeader extends LitElement {
${this.renderCount()} ${this.renderCount()}
<div class="spacer"></div> <div class="spacer"></div>
${this.renderScope()} ${this.renderSort()} ${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> <slot name="actions"></slot>
</header> </header>
`; `;
} }
protected override updated(): void {
const key = [
this.heading,
this.count,
this.countNoun,
this.countPlural,
this.searchTerm,
this.sortOptions.length,
this.sortField,
this.sortDirection,
this.busy,
this.actions.map((a) => `${a.id}:${a.label}:${a.disabled ?? false}`).join(','),
].join('|');
if (key === this.lastFitKey) return;
this.lastFitKey = key;
this.measureFit();
}
// =================================================================
// What fits
// =================================================================
/**
* Decide which actions are buttons and which are menu items.
*
* Two things about the shape of this are load-bearing.
*
* **Every pass starts from all-visible**, so the collapsed set is a
* pure function of the current width rather than of the order the
* widths arrived in. A rule that only ever *added* to the set would
* never give an action back when the window grew, and one that
* adjusted by a step would need a hysteresis band to stop it
* oscillating on the pixel where a button exactly fits.
*
* **It flips `hidden` on the rendered nodes rather than re-rendering
* between steps.** Reading `scrollWidth` forces layout, which is the
* point; awaiting a Lit update between steps instead would let the
* intermediate all-visible state paint, so the fix would flash the
* overflow it exists to prevent. The reactive state is set once, at
* the end, and the next render agrees with what was measured.
*
* The budget is the *header's* overflow and not the actions row's,
* because the count and the sort control are `flex-shrink: 0` and
* are therefore competing for the same width only `.scope` gives
* way, which is what it has an ellipsis for.
*/
private measureFit(): void {
const header = this.headerEl;
if (!header) return;
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
'[data-action-id]',
)) {
const id = el.dataset['actionId'];
if (id !== undefined) buttons.set(id, el);
}
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
* overflowing, and the difference is a trap worth naming.
*
* Once the title can ellipsis, it absorbs the pressure and
* `scrollWidth` reports a header that fits perfectly while the
* heading reads "Playlis…". That is this issue's own failure
* mode moved from the button to the title, and it is invisible
* to exactly the same measurement that missed it the first time.
* So the title's own truncation counts as not fitting, and
* collapsing an action is tried before the title gives way.
*/
const fits = () =>
header.scrollWidth <= header.clientWidth &&
(title === null || title.scrollWidth <= title.clientWidth + 1);
for (const el of buttons.values()) el.hidden = false;
if (more) more.hidden = true;
if (count) count.hidden = false;
const collapsed = new Set<string>();
if (!fits()) {
if (more) more.hidden = false;
for (const action of this.collapseOrder()) {
collapsed.add(action.id);
const el = buttons.get(action.id);
if (el) el.hidden = true;
if (fits()) break;
}
}
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. */
private collapseOrder(): PageAction[] {
return this.actions
.map((action, index) => ({ action, index }))
.sort(
(a, b) =>
(a.action.priority ?? 0) - (b.action.priority ?? 0) ||
b.index - a.index,
)
.map(({ action }) => action);
}
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));
if (same) return;
this.collapsed = next;
// Nothing left to show in it. Closing rather than leaving an
// empty menu open is the same rule the shelves follow.
if (next.size === 0 && this.menuOpen) this.closeMenu();
}
// =================================================================
// Rendering
// =================================================================
private renderActions() {
if (this.actions.length === 0) return nothing;
const overflowed = this.actions.filter((a) => this.collapsed.has(a.id));
return html`
<div class="actions">
${this.actions.map((a) => this.renderActionButton(a))}
<wa-popup
placement="bottom-end"
flip
shift
.active=${this.menuOpen}
>
<button
slot="anchor"
class="more-button"
type="button"
data-testid="page-actions-more"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded=${this.menuOpen ? 'true' : 'false'}
aria-controls="page-header-overflow"
?hidden=${overflowed.length === 0}
@click=${this.onMoreClick}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
<div
id="page-header-overflow"
class="context-menu-panel"
role="menu"
aria-label="More actions"
>
${overflowed.map(
(a) => html`
<wa-dropdown-item
?disabled=${a.disabled ?? false}
@click=${() => this.onActionSelect(a)}
>
<wa-icon
slot="icon"
name=${a.icon}
></wa-icon>
${a.label}
</wa-dropdown-item>
`,
)}
</div>
</wa-popup>
</div>
`;
}
private renderActionButton(a: PageAction) {
const drop = a.drop;
return html`
<button
class="action ${drop?.active === true ? 'drag-over' : ''}"
type="button"
data-action-id=${a.id}
data-testid=${`page-action-${a.id}`}
title=${a.title ?? nothing}
?disabled=${a.disabled ?? false}
?hidden=${this.collapsed.has(a.id)}
@click=${() => a.onSelect()}
@dragover=${(e: DragEvent) => drop?.onDragOver(e)}
@dragleave=${(e: DragEvent) => drop?.onDragLeave(e)}
@drop=${(e: DragEvent) => drop?.onDrop(e)}
>
<wa-icon name=${a.icon}></wa-icon>
${a.label}
</button>
`;
}
// =================================================================
// The overflow menu
// =================================================================
private onActionSelect(a: PageAction): void {
if (a.disabled === true) return;
this.closeMenu();
a.onSelect();
}
private onMoreClick = (): void => {
if (this.menuOpen) {
this.closeMenu();
return;
}
this.menuOpen = true;
void this.updateComplete.then(() => {
if (!this.menuOpen) return;
this.popup?.reposition();
this.menuKeyboard.open(this.menuPanel ?? null, this.moreButton);
this.attachOutsideClose();
});
};
private closeMenu(): void {
if (!this.menuOpen) return;
this.detachOutsideClose();
this.menuKeyboard.close();
this.menuOpen = false;
}
/**
* A click anywhere else closes it. `composedPath` rather than
* `contains`, because the trigger and the panel are both inside
* this shadow root and a click retargets at the host.
*/
private onOutsideDown = (e: Event): void => {
if (e.composedPath().includes(this.menuPanel as EventTarget)) return;
if (e.composedPath().includes(this.moreButton as EventTarget)) return;
this.closeMenu();
};
private attachOutsideClose(): void {
if (this.outsideCloseAttached) return;
this.outsideCloseAttached = true;
document.addEventListener('mousedown', this.onOutsideDown, true);
}
private detachOutsideClose(): void {
if (!this.outsideCloseAttached) return;
this.outsideCloseAttached = false;
document.removeEventListener('mousedown', this.onOutsideDown, true);
}
private renderCount() { private renderCount() {
if (this.count === null) return nothing; if (this.count === null) return nothing;
@@ -227,7 +812,16 @@ export class PageHeader extends LitElement {
const noun = this.count === 1 ? this.countNoun : plural; 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 >${this.count.toLocaleString()} ${noun}</span
>`; >`;
} }
@@ -258,7 +852,10 @@ export class PageHeader extends LitElement {
if (this.sortOptions.length === 1) { if (this.sortOptions.length === 1) {
return html` return html`
<div class="sort"> <div class="sort">
<span>Sort: ${this.sortOptions[0]?.label}</span> <span
><span class="sort-label">Sort: </span
>${this.sortOptions[0]?.label}</span
>
${this.renderDirectionButton(ascending)} ${this.renderDirectionButton(ascending)}
</div> </div>
`; `;
@@ -267,7 +864,7 @@ export class PageHeader extends LitElement {
return html` return html`
<div class="sort"> <div class="sort">
<label> <label>
Sort: <span class="sort-label">Sort:</span>
<select <select
data-testid="page-sort" data-testid="page-sort"
.value=${this.sortField} .value=${this.sortField}

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