Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2365806d18 | ||
|
|
7d348f243a | ||
|
|
ddd04623f7 | ||
|
|
9ad1477b1e | ||
|
|
70ab3ddf94 | ||
|
|
4f7529c315 | ||
|
|
bb21072386 | ||
|
|
ead1354e4d | ||
|
|
ae85df0dad | ||
|
|
6e7e349e63 | ||
|
|
f9ba9a87d7 | ||
|
|
e4efec6f0c | ||
|
|
99f355b2fc | ||
|
|
c79d4d47a3 | ||
|
|
8efed2dd2b | ||
|
|
12af6ec1f7 | ||
|
|
f3d1ae1c8c | ||
|
|
5af545e38d | ||
|
|
9da3967dd9 | ||
|
|
c8d94a8203 | ||
|
|
43d78a731a | ||
|
|
a3926704cc | ||
|
|
ea53d4f15b | ||
|
|
ea16e07c46 | ||
|
|
4f47c85208 | ||
|
|
603728a3fb | ||
|
|
018d857746 | ||
|
|
a7ac2b4a3e | ||
|
|
d347809e6e | ||
|
|
a5ffcc22e3 | ||
|
|
f18691560d | ||
|
|
c84a9069ef |
@@ -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/` —
|
||||||
|
|||||||
@@ -3696,3 +3696,268 @@ as a tidy-up, a change nothing on a desktop renders differently.
|
|||||||
Related: a width-gated decision **is** testable at both tiers, which is
|
Related: a width-gated decision **is** testable at both tiers, which is
|
||||||
why #61's phone mini player is a `matchMedia` stub in the component test
|
why #61's phone mini player is a `matchMedia` stub in the component test
|
||||||
and needs nothing special.
|
and needs nothing special.
|
||||||
|
|
||||||
|
## A default that is an *absent* key survives an existing seed (2026-08-19)
|
||||||
|
|
||||||
|
The skill warns that a seed freezes every default it has already
|
||||||
|
persisted, so changing one in `backend/config` is invisible against an
|
||||||
|
existing `YJ_HOME` while CI, which seeds by running the app, tests the
|
||||||
|
new one. That warning is about defaults stored as *values*.
|
||||||
|
|
||||||
|
#25's Autotag-hidden default is stored as the **absence of a key**:
|
||||||
|
`GeneralConfig.ViewVisibility` is a map, an id it does not mention takes
|
||||||
|
`backend/config.Views`' answer, and only what the user changed is ever
|
||||||
|
written. So a seed built before the feature existed showed the new
|
||||||
|
default immediately — verified against `.dev/seeds/default.tar`, whose
|
||||||
|
`config.toml` has no `[General.ViewVisibility]` table at all, and whose
|
||||||
|
sidebar came up without Autotag on the first launch of the new binary.
|
||||||
|
After toggling it on and off again the file carries exactly one line,
|
||||||
|
`autotag = false`.
|
||||||
|
|
||||||
|
The general form is worth keeping: **a default expressed as a zero value
|
||||||
|
needs a re-seed to observe; a default expressed as an absent key does
|
||||||
|
not**, and it needs no migration for existing installs either. It is the
|
||||||
|
same property that makes removing a view later free (an unknown key is
|
||||||
|
dropped on load), which is what the `#25 → #27` ordering on #73 rests on.
|
||||||
|
|
||||||
|
## A spec cannot assume a destination has a nav item (2026-08-19)
|
||||||
|
|
||||||
|
Since #25, `getByTestId('nav-<view>')` is not a reliable way to reach a
|
||||||
|
view: Autotag is hidden by default and Downloads is absent without a
|
||||||
|
download client, so four existing specs failed on a 30 s timeout waiting
|
||||||
|
for a locator that will never resolve. `navigateTo(page, view)` in
|
||||||
|
`e2e/support/fixtures.ts` dispatches the app's own `navigate` event
|
||||||
|
instead, which is what every nav item, card and detail view dispatches —
|
||||||
|
so it is the mechanism and not a test-only door.
|
||||||
|
|
||||||
|
Use the nav item when the *nav* is the subject, and `navigateTo` when
|
||||||
|
the view is.
|
||||||
|
|
||||||
|
## `config-section .header` is ambiguous once a job exists (2026-08-19)
|
||||||
|
|
||||||
|
#27 embeds `<job-panel>` inside four Settings sections, and a panel with
|
||||||
|
any job in it also mounts a `job-details-drawer` — whose own header
|
||||||
|
carries the class `.header`. So `config-page config-section .header`,
|
||||||
|
which `settings-reach.spec.ts` had used since plan 007, resolves to two
|
||||||
|
elements and fails Playwright's strict mode the moment a scan has run.
|
||||||
|
|
||||||
|
Two things follow. A spec asserting on a section's *disclosure* should
|
||||||
|
locate it by role and name (`getByRole('button', {name: heading})`) or
|
||||||
|
scope per section and take `.first()`, not by that class. And this is a
|
||||||
|
worked example of the more general trap: a class name is not a
|
||||||
|
selector's contract, and a component that embeds another inherits its
|
||||||
|
class names into every ancestor query.
|
||||||
|
|
||||||
|
It also only appears in a suite that has *done* something — the
|
||||||
|
sections are empty on a fresh app, so this cannot be reproduced by
|
||||||
|
opening Settings and looking.
|
||||||
|
|
||||||
|
**And it appears on the second engine, not the first.** CI runs
|
||||||
|
chromium then webkit against **one app**, so a spec that scans in the
|
||||||
|
chromium pass leaves a finished job the webkit pass then trips over.
|
||||||
|
Three specs used that selector; two failed locally and the third
|
||||||
|
(`failure-voice.spec.ts`) was green on chromium and red on webkit in
|
||||||
|
the same run. Reproducing it locally is running the suite twice against
|
||||||
|
one `make dev-headless` — which is worth doing for any change that
|
||||||
|
leaves state behind, since it is the only place a cross-engine order
|
||||||
|
dependency shows up.
|
||||||
|
|
||||||
|
## `scrollWidth` counts the left padding and not the right (measured 2026-08-20)
|
||||||
|
|
||||||
|
The obvious predicate for "does this flex row fit" is
|
||||||
|
`el.scrollWidth <= el.clientWidth`, and on a box with symmetric gutters
|
||||||
|
it **under-reports by one gutter**. `scrollWidth` is the extent of the
|
||||||
|
scrollable content area, which includes `padding-left` and excludes
|
||||||
|
`padding-right`; `clientWidth` includes both. So a child may end up to
|
||||||
|
`padding-right` past where content is allowed to go while the box
|
||||||
|
reports a perfect fit.
|
||||||
|
|
||||||
|
Measured on the top bar (`padding: 0 2em`) at 700x600 with a long-titled
|
||||||
|
scan staged: `clientWidth 700`, `scrollWidth 700` — and
|
||||||
|
`job-indicator`'s right edge at 700 against a content edge of 668, i.e.
|
||||||
|
sitting in the whole right gutter. `#143`'s first fix passed its own
|
||||||
|
measurement and left the indicator visibly jammed against the window
|
||||||
|
edge.
|
||||||
|
|
||||||
|
The predicate `services/top-bar-fit.ts` uses instead is the one its
|
||||||
|
spec asserts: no in-flow child's rect outside the parent's *content*
|
||||||
|
box, both edges, with half a pixel of slack for fractional flex widths.
|
||||||
|
|
||||||
|
This is the same family as #69's title trap — the measurement easiest to
|
||||||
|
reach for is the one that cannot see the failure — and it is worth
|
||||||
|
knowing before writing the next one of these: **the fit test and the
|
||||||
|
assertion that proves it should be the same test.** It was found only
|
||||||
|
because `top-bar-fit.spec.ts` measures per child rather than asserting
|
||||||
|
on the container, which is exactly why #69 needed
|
||||||
|
`header-action-overflow.spec.ts`.
|
||||||
|
|
||||||
|
## The top bar's overflow is 11px idle and 262px while working (measured 2026-08-20)
|
||||||
|
|
||||||
|
#143 was filed as "11px at 600x600" and re-measured as 171. Both are the
|
||||||
|
same defect seen with different jobs running: `job-indicator` is
|
||||||
|
`hidden` when idle, ~144px wide showing "Scanning Music", and **235px**
|
||||||
|
showing a real library's scan title ("Scanning Music from the external
|
||||||
|
drive"), because the label is capped at 12rem and gets there.
|
||||||
|
|
||||||
|
Swept against the running app with that job staged, `header.top-bar`
|
||||||
|
client vs scroll:
|
||||||
|
|
||||||
|
| width | idle | with the long-titled scan |
|
||||||
|
|---|---|---|
|
||||||
|
| 320, 390, 599 | fits | fits (the phone rules drop the filter and the label) |
|
||||||
|
| 600 | 611 | **862** |
|
||||||
|
| 700 | fits | 862 |
|
||||||
|
| 800 | fits | 862 |
|
||||||
|
| 899 | fits | 899 (fits) |
|
||||||
|
| 900 | fits | 946 |
|
||||||
|
| 1100, 1440 | fits | fits |
|
||||||
|
|
||||||
|
Two things worth keeping. The band is **600–610 idle and 600–900 while
|
||||||
|
working**, so "a narrow corner" and "the header is crowded from 900
|
||||||
|
down" are both true and the difference is entirely what is in flight —
|
||||||
|
which is the case a seeded, settled app can never show you. And 899
|
||||||
|
fits while 900 does not, because `nav-history` appears at 900: the worst
|
||||||
|
width for the header is not the narrowest one, the same way 900 rather
|
||||||
|
than 800 is the worst width for the content area.
|
||||||
|
|
||||||
|
Staging it is `/__test/emit` with a `JobsChanged` snapshot; a job with
|
||||||
|
`state: "running"` never completes, so it stays up until an empty
|
||||||
|
snapshot is emitted, which is what makes an idle re-measurement look
|
||||||
|
like the fix not working.
|
||||||
|
|
||||||
|
## Two repaint mechanisms, and neither is pinned alone (measured 2026-08-20)
|
||||||
|
|
||||||
|
`CLAUDE.md` already states the rule — *a virtualized list repaints only
|
||||||
|
when you tell it to, and the accidental way you were telling it may be
|
||||||
|
the thing you are about to delete* — found in `artists-view` and
|
||||||
|
`genres-view`. `queue-panel` is a second instance with numbers, and the
|
||||||
|
numbers are the part worth keeping.
|
||||||
|
|
||||||
|
It repaints its rows **two** ways:
|
||||||
|
|
||||||
|
- `onSelectionChanged()` calls `virtualizer.requestUpdate()`, which is
|
||||||
|
the intended one and the one `track-list` has always had;
|
||||||
|
- `.keyFunction=${(track) => track.id}` is a **per-render arrow**, so it
|
||||||
|
is a changed property on every host update and repaints the rows by
|
||||||
|
itself.
|
||||||
|
|
||||||
|
Removing *either* alone changes nothing observable. That is why #43
|
||||||
|
could not be settled by reading the code: the hypothesis in its Findings
|
||||||
|
(the repaint is missing) was checkable, false, and would have looked
|
||||||
|
identical either way.
|
||||||
|
|
||||||
|
Removing **both** does not break selection either — it delays it. Time
|
||||||
|
from click to `aria-selected`, three clicks each:
|
||||||
|
|
||||||
|
| build | ms to highlight |
|
||||||
|
|---|---|
|
||||||
|
| healthy | 5, 16, 17 |
|
||||||
|
| both mechanisms removed | 134, 3,866, 5,816 |
|
||||||
|
|
||||||
|
The highlight arrives on whatever unrelated render happens next (the
|
||||||
|
player's 1 Hz position report is the usual candidate). **Four seconds is
|
||||||
|
indistinguishable from broken to a user, and invisible to a spec** —
|
||||||
|
`expect.poll`'s default 5 s timeout passes the degraded build on every
|
||||||
|
assertion. `queue-selection.spec.ts` bounds its selection assertions at
|
||||||
|
500 ms for that reason, which is ~30x the healthy case and an order of
|
||||||
|
magnitude under the degraded one.
|
||||||
|
|
||||||
|
The general form, for the next spec about anything push-driven: **a poll
|
||||||
|
generous enough to be stable is generous enough to miss a latency
|
||||||
|
regression entirely.** If "late" is a failure mode worth having, the
|
||||||
|
timeout has to say so.
|
||||||
|
|
||||||
|
## A hit-scan says how much of a row is not selectable (measured 2026-08-20)
|
||||||
|
|
||||||
|
`explore-link` stops the click's propagation on purpose — "the row must
|
||||||
|
not also treat it as a selection" — so a click on a track, album or
|
||||||
|
artist *name* navigates and selects nothing. That is app-wide and
|
||||||
|
deliberate, and the useful question about any given list is how much of
|
||||||
|
its row it costs.
|
||||||
|
|
||||||
|
Asking `elementFromPoint` what is under each x across a row, at three
|
||||||
|
heights:
|
||||||
|
|
||||||
|
| list | link coverage |
|
||||||
|
|---|---|
|
||||||
|
| queue panel | 12% |
|
||||||
|
| track list | 21% |
|
||||||
|
|
||||||
|
This killed a fix in progress. #43 reads as "selection is broken in the
|
||||||
|
queue panel, and fine in the track list", the obvious mechanism is that
|
||||||
|
the queue's narrow rows are mostly name, and it is **wrong**: the panel
|
||||||
|
is *less* link-covered than the list it is being compared against. The
|
||||||
|
scan takes a minute and is worth running before demoting anybody's links
|
||||||
|
— `explore-album-details`'s tracklist (number / title / artist /
|
||||||
|
duration) is the one that plausibly *is* mostly link, and is the one
|
||||||
|
#5 is about to add selection to.
|
||||||
|
|
||||||
|
## A layout is still moving when a guard says it has arrived (measured 2026-08-20)
|
||||||
|
|
||||||
|
`album-dropdown.spec.ts` failed with `Expected 80, Received 10` twice
|
||||||
|
over two sessions, and #133 already strengthened its guard from
|
||||||
|
"scrollable at all" to "has at least the range the assertion needs".
|
||||||
|
That was necessary and could not be sufficient, and the reason is
|
||||||
|
structural rather than a matter of thresholds: **a guard and the write
|
||||||
|
it guards are separate CDP round trips**, so the page is free to
|
||||||
|
re-lay-out between them. Polling harder cannot close a window between
|
||||||
|
two moments; only removing the window can.
|
||||||
|
|
||||||
|
Measured directly, sampling `scrollHeight - clientHeight` on
|
||||||
|
`.grid-scroll-container` every frame across a 1440x900 → 900x600 resize,
|
||||||
|
three runs:
|
||||||
|
|
||||||
|
| t (ms) | range |
|
||||||
|
|---|---|
|
||||||
|
| 0 | 0 |
|
||||||
|
| 1 | **88** |
|
||||||
|
| 8–14 | 330 (settled) |
|
||||||
|
|
||||||
|
88 satisfies a guard asking for 80 and is not the settled value, so the
|
||||||
|
guard can pass while the grid is one layout pass from done. Under
|
||||||
|
full-suite load the transient is worse — the observed failure had 10 —
|
||||||
|
which is why it shows up on the second run of a suite and not in ten
|
||||||
|
consecutive runs of the file alone (0/10 both before and after the fix).
|
||||||
|
|
||||||
|
The shape to write instead: **one page-side call that performs the
|
||||||
|
action and returns what it observes**, with `expect.poll` retrying
|
||||||
|
*that*. `scrollTo()` sets `scrollTop` and returns `scrollTop`, so the
|
||||||
|
assertion is about what the grid did rather than about what it was
|
||||||
|
ready to do. `layout-overflow.spec.ts`'s sidebar probe already had the
|
||||||
|
fused half and was missing the retry; it has both now.
|
||||||
|
|
||||||
|
Worth generalising: a spec that resizes and then measures is asserting
|
||||||
|
about a moving target for the next dozen frames. Fuse, then poll.
|
||||||
|
|
||||||
|
## "The first N tracks" is not a way to ask for an ordinary one (2026-08-20)
|
||||||
|
|
||||||
|
`queue-selection.spec.ts` staged its queue from the first few rows of
|
||||||
|
`library.Library.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 at all — `01 Tone A`, `02 Tone B`,
|
||||||
|
`Title Only`, `no-tags-at-all` — and a name with nothing to route to
|
||||||
|
renders as **plain text**, not as a link.
|
||||||
|
|
||||||
|
Two things follow, and the second is the sharper one.
|
||||||
|
|
||||||
|
**The order is the scan's.** `GetTracks` returns `audio_files.id` order,
|
||||||
|
i.e. the order the scan inserted rows, which depends on concurrency and
|
||||||
|
directory traversal. Locally the first eight are all from two proper
|
||||||
|
albums, so the spec passed twice over; CI rebuilds its seed with a real
|
||||||
|
scan, got a different eight, and failed on both engines. This is the
|
||||||
|
same family as "a seed freezes every default it has already persisted" —
|
||||||
|
the fixture library is not a list, it is a *set* with an incidental
|
||||||
|
order, and no spec should depend on that order.
|
||||||
|
|
||||||
|
**A loose locator hid it.** The row was located with
|
||||||
|
`.locator('.explore-link').first()`, and a row has two — the title and
|
||||||
|
the artist. When the title is plain text, `first()` silently resolves to
|
||||||
|
the **artist** link, so the click went somewhere real and the assertion
|
||||||
|
was about a destination the test had not exercised. `.track-title
|
||||||
|
.explore-link` is the locator that says which one it means; the loose
|
||||||
|
one turned a fixture problem into a mystery.
|
||||||
|
|
||||||
|
The general rule for this repo's fixture library: it is deliberately
|
||||||
|
full of edge cases (untagged, unicode, duplicates, extremes), so a spec
|
||||||
|
that wants an *ordinary* track has to **say so** — filter on the
|
||||||
|
property it depends on rather than slicing.
|
||||||
|
|||||||
@@ -514,6 +514,36 @@ 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 untouched and is still the one view of
|
||||||
|
everything at once, from every page. 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.
|
||||||
- `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
|
||||||
@@ -945,13 +975,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
|
||||||
@@ -1336,6 +1520,58 @@ 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
|
themselves already existed; what was new is that they are a promise and
|
||||||
that the queue panel is inside it.
|
that the queue panel is inside it.
|
||||||
|
|
||||||
|
**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` is the only control in the app that calls
|
||||||
|
`setSelectedLibrary` — so it trades this promise for the same promise
|
||||||
|
(#148 is the phone already doing that). Collapsing the search box to an
|
||||||
|
icon is what #57 wants and #57 is blocked behind #62, so building it
|
||||||
|
here is building it without the thing that blocks 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.
|
||||||
|
|
||||||
**900 is the worst desktop width, not the 800×600 minimum.** The
|
**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
|
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
|
899 and 700px at 900 — the narrowest content area any desktop width
|
||||||
|
|||||||
@@ -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,81 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
|
|||||||
return nil
|
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 {
|
||||||
|
|||||||
+86
-26
@@ -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,8 +32,22 @@ 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,
|
||||||
@@ -57,7 +57,17 @@ type GeneralConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 +81,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 +101,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -110,27 +110,30 @@ test.describe('the album dropdown', () => {
|
|||||||
await app.setViewportSize({ width: 900, height: 600 });
|
await app.setViewportSize({ width: 900, height: 600 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wait for the range the assertion below actually needs, not for
|
// The container has to be a scroller at all, which is the thing
|
||||||
// "scrollable at all" (#133). The guard used to be
|
// the defect behind this spec broke and is a property rather
|
||||||
// `scrollHeight > clientHeight + 40` while the next line asks to
|
// than a moment.
|
||||||
// reach 80, so any range in 41-79 satisfied it and could not
|
|
||||||
// satisfy the assertion — and the grid passes through exactly
|
|
||||||
// that while it settles, because it recomputes its columns after
|
|
||||||
// the resize rather than during it. The settled range here is
|
|
||||||
// 330, so this waits rather than weakening anything.
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => scrollRange(app))
|
.poll(() => scrollRange(app))
|
||||||
.toMatchObject({ room: true, overflowY: 'auto' });
|
.toMatchObject({ overflowY: 'auto' });
|
||||||
|
|
||||||
await app.evaluate((target) => {
|
// **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 = target;
|
// `evaluate` calls, so the grid can satisfy the range and settle
|
||||||
}, SCROLL_TARGET);
|
// 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(SCROLL_TARGET);
|
// 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
|
||||||
@@ -261,7 +264,7 @@ 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((target) => {
|
return app.evaluate((target) => {
|
||||||
const sc = document
|
const sc = document
|
||||||
@@ -269,22 +272,37 @@ async function scrollRange(app: Page) {
|
|||||||
?.shadowRoot?.querySelector('.grid-scroll-container');
|
?.shadowRoot?.querySelector('.grid-scroll-container');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// `room` is the precondition of the assertion that follows it:
|
// Reported for the failure message rather than waited on: `room`
|
||||||
// enough range to actually reach the target. A threshold below
|
// was the guard #133 strengthened, and #151 is that a guard in
|
||||||
// what the caller depends on is not a guard.
|
// 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,
|
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
|
||||||
overflowY: sc ? getComputedStyle(sc).overflowY : '',
|
overflowY: sc ? getComputedStyle(sc).overflowY : '',
|
||||||
};
|
};
|
||||||
}, SCROLL_TARGET);
|
}, 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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,14 @@ const VIEWPORTS = [
|
|||||||
// was missing its own worst case.
|
// was missing its own worst case.
|
||||||
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
|
{ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,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();
|
||||||
@@ -123,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
|
||||||
@@ -245,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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 2–6 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!,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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 a phone, where the answer must be that
|
||||||
|
* nothing collapses because the media queries already did the work.
|
||||||
|
*/
|
||||||
|
const WIDTHS = [390, 600, 800, 899, 900, 1440];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', () => {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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+\)$/);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,6 +111,35 @@ 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' });
|
||||||
|
}
|
||||||
|
|
||||||
/** 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) {}
|
||||||
|
|||||||
@@ -112,6 +112,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.
|
||||||
*/
|
*/
|
||||||
@@ -247,6 +257,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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+69
-1
@@ -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 {
|
||||||
@@ -346,7 +408,13 @@ body div.sidebar {
|
|||||||
|
|
||||||
/* The search box is the one header control worth its width; the
|
/* The search box is the one header control worth its width; the
|
||||||
library filter is a rarely-changed setting and reachable from
|
library filter is a rarely-changed setting and reachable from
|
||||||
the drawer's Settings. */
|
the drawer's Settings.
|
||||||
|
|
||||||
|
`nav-history` is already gone from 899 down. It would belong
|
||||||
|
here anyway and for a stronger reason than width: the phone has
|
||||||
|
Back as a gesture or a button the OS owns, and this app hooks it
|
||||||
|
(`popstate`), so a second Back in the chrome duplicates a
|
||||||
|
control the platform provides. */
|
||||||
.top-bar library-filter {
|
.top-bar library-filter {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
<!-- 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>
|
||||||
|
|||||||
+114
-20
@@ -23,6 +23,7 @@ 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';
|
||||||
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';
|
||||||
@@ -40,6 +41,8 @@ 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 +54,7 @@ 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 { installTopBarFit } from './src/services/top-bar-fit';
|
||||||
import {
|
import {
|
||||||
hasTrackPayload,
|
hasTrackPayload,
|
||||||
getDragPayload,
|
getDragPayload,
|
||||||
@@ -70,6 +74,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 +110,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 +127,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,45 +224,101 @@ 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 });
|
||||||
});
|
});
|
||||||
@@ -279,6 +345,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
|
||||||
@@ -497,7 +577,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'
|
||||||
@@ -507,14 +598,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 },
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
@@ -3207,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,
|
||||||
@@ -27,6 +33,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';
|
||||||
@@ -71,6 +80,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);
|
||||||
|
|
||||||
@@ -469,6 +499,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 +888,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 +926,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?.();
|
||||||
@@ -1084,6 +1129,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 +1600,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 +1695,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 +1829,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}
|
||||||
@@ -1678,6 +1862,79 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Theme section ---
|
// --- Theme section ---
|
||||||
|
|
||||||
private renderThemeSection() {
|
private renderThemeSection() {
|
||||||
@@ -2056,8 +2313,8 @@ 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="scan-actions">
|
<div class="scan-actions">
|
||||||
@@ -2067,6 +2324,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 +2378,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 +2402,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 +2455,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
|
||||||
|
|||||||
@@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
@property({ type: String })
|
||||||
|
kinds = '';
|
||||||
|
|
||||||
|
/** Heading above the rows. Omitted renders no heading. */
|
||||||
|
@property({ type: String })
|
||||||
|
heading = '';
|
||||||
|
|
||||||
|
@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 wanted = this.wanted;
|
||||||
|
|
||||||
|
return this.jobs.filter((job) => wanted.has(job.kind as JobKind));
|
||||||
|
}
|
||||||
|
|
||||||
|
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="full"
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,6 +277,26 @@ export class QueuePanel
|
|||||||
return this.queue.tracks.length;
|
return this.queue.tracks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repaint the rows when the selection changes.
|
||||||
|
*
|
||||||
|
* `<lit-virtualizer>` renders through the `virtualize` directive,
|
||||||
|
* which reacts to its *own* properties and not to the host having
|
||||||
|
* re-rendered, so host state like a selection reaches the rows only
|
||||||
|
* if it is pushed. `track-list` has always done this and both
|
||||||
|
* playlist views had to be taught it.
|
||||||
|
*
|
||||||
|
* **There is a second, accidental mechanism here and it must not be
|
||||||
|
* mistaken for this one**: `.keyFunction` below is a per-render
|
||||||
|
* arrow, so it is a changed property on every host update and
|
||||||
|
* repaints the rows by itself. Removing *either* alone changes
|
||||||
|
* nothing observable, which is why #43 could not be settled by
|
||||||
|
* reading the code. With both gone the highlight still arrives —
|
||||||
|
* on whatever unrelated render happens next, measured at 134ms,
|
||||||
|
* 3,866ms and 5,816ms against 5–17ms healthy, which a user cannot
|
||||||
|
* tell from broken. `queue-selection.spec.ts` asserts the
|
||||||
|
* *promptness* rather than the eventual state for that reason.
|
||||||
|
*/
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
this.virtualizer?.requestUpdate();
|
this.virtualizer?.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,10 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
|
|
||||||
import type { DragActiveDetail } from '@utils/drag-controller';
|
import type { DragActiveDetail } from '@utils/drag-controller';
|
||||||
import {
|
import { ActiveViewController } from '@store/controllers/active-view-controller';
|
||||||
ICON_PLAYLIST,
|
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
|
||||||
ICON_AUTOTAG,
|
import { VIEW_META } from '../../services/view-meta';
|
||||||
ICON_REQUESTED,
|
import type { View } from '../../services/view-meta';
|
||||||
} from '@utils/icon-language';
|
|
||||||
|
|
||||||
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
id: View;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_WIDTH = 56;
|
const MIN_WIDTH = 56;
|
||||||
const MAX_WIDTH = 400;
|
const MAX_WIDTH = 400;
|
||||||
@@ -159,11 +150,20 @@ export class AppSidebar extends LitElement {
|
|||||||
/** Delay in ms before a drag-hover triggers navigation. */
|
/** Delay in ms before a drag-hover triggers navigation. */
|
||||||
private static readonly HOVER_NAV_DELAY = 600;
|
private static readonly HOVER_NAV_DELAY = 600;
|
||||||
|
|
||||||
/** Home, because that is where `index.ts` now navigates on startup
|
/**
|
||||||
* (H-8). The sidebar does not hear a `navigate` it did not send,
|
* Which item is lit, read from the shell rather than tracked here.
|
||||||
* so this default is what keeps `aria-current` honest on arrival. */
|
*
|
||||||
@state()
|
* This used to be a `@state()` field defaulting to `home` -- the
|
||||||
private activeView: View = 'home';
|
* landing view -- because "the sidebar does not hear a `navigate`
|
||||||
|
* it did not send". That default was the only honest moment it
|
||||||
|
* ever had: a back-navigation dispatches no `navigate`, so the
|
||||||
|
* highlight stayed on the view the user had just left (#72), and
|
||||||
|
* the copy of this component that `bottom-nav` mounts inside its
|
||||||
|
* drawer opened on `home` from whatever page you were standing on.
|
||||||
|
* The shell publishes the active view now, so there is nothing to
|
||||||
|
* default and nothing to keep in step.
|
||||||
|
*/
|
||||||
|
private activeCtrl = new ActiveViewController(this);
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private isDragging = false;
|
private isDragging = false;
|
||||||
@@ -200,19 +200,15 @@ export class AppSidebar extends LitElement {
|
|||||||
typeof setTimeout
|
typeof setTimeout
|
||||||
> | null = null;
|
> | null = null;
|
||||||
|
|
||||||
private navItems: NavItem[] = [
|
/**
|
||||||
{ id: 'home', label: 'Home', icon: 'house' },
|
* Which destinations the user has kept (#25). The list below is
|
||||||
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
* still the whole set and its order -- this only filters it, and
|
||||||
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
* only for drawing: a hidden view is still reachable by `navigate`,
|
||||||
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
* which is what detail views and the launch page depend on.
|
||||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
*/
|
||||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
private visibilityCtrl = new ViewVisibilityController(this);
|
||||||
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
|
||||||
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
private navItems = VIEW_META;
|
||||||
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
|
||||||
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
|
|
||||||
{ id: 'settings', label: 'Settings', icon: 'gear' },
|
|
||||||
];
|
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -237,10 +233,6 @@ export class AppSidebar extends LitElement {
|
|||||||
'yj-drag-active',
|
'yj-drag-active',
|
||||||
this.onDragActive as EventListener,
|
this.onDragActive as EventListener,
|
||||||
);
|
);
|
||||||
document.addEventListener(
|
|
||||||
'navigate',
|
|
||||||
this.onGlobalNavigate as EventListener,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
@@ -262,10 +254,6 @@ export class AppSidebar extends LitElement {
|
|||||||
'yj-drag-active',
|
'yj-drag-active',
|
||||||
this.onDragActive as EventListener,
|
this.onDragActive as EventListener,
|
||||||
);
|
);
|
||||||
document.removeEventListener(
|
|
||||||
'navigate',
|
|
||||||
this.onGlobalNavigate as EventListener,
|
|
||||||
);
|
|
||||||
this.clearDragHoverTimer();
|
this.clearDragHoverTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,9 +269,12 @@ export class AppSidebar extends LitElement {
|
|||||||
></div>
|
></div>
|
||||||
<nav aria-label="Main">
|
<nav aria-label="Main">
|
||||||
<ul>
|
<ul>
|
||||||
${this.navItems.map((item) => {
|
${this.navItems
|
||||||
|
.filter((item) => this.visibilityCtrl.visible(item.id))
|
||||||
|
.map((item) => {
|
||||||
|
const active = this.activeCtrl.isActive(item.id);
|
||||||
const classes = [
|
const classes = [
|
||||||
this.activeView === item.id
|
active
|
||||||
? 'active'
|
? 'active'
|
||||||
: '',
|
: '',
|
||||||
this.dragHoverView === item.id
|
this.dragHoverView === item.id
|
||||||
@@ -299,7 +290,7 @@ export class AppSidebar extends LitElement {
|
|||||||
type="button"
|
type="button"
|
||||||
class=${classes}
|
class=${classes}
|
||||||
data-testid="nav-${item.id}"
|
data-testid="nav-${item.id}"
|
||||||
aria-current=${this.activeView === item.id
|
aria-current=${active
|
||||||
? 'page'
|
? 'page'
|
||||||
: 'false'}
|
: 'false'}
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
@@ -382,19 +373,6 @@ export class AppSidebar extends LitElement {
|
|||||||
private static readonly DROP_VIEWS: Set<View> =
|
private static readonly DROP_VIEWS: Set<View> =
|
||||||
new Set(['playlists']);
|
new Set(['playlists']);
|
||||||
|
|
||||||
/** Keeps the highlighted nav item in sync with navigation that
|
|
||||||
* originates outside the sidebar itself (e.g. the launch-page
|
|
||||||
* dispatch in index.ts). */
|
|
||||||
private onGlobalNavigate = (
|
|
||||||
e: CustomEvent<{ view?: string }>,
|
|
||||||
) => {
|
|
||||||
const view = e.detail.view;
|
|
||||||
|
|
||||||
if (view && this.navItems.some((item) => item.id === view)) {
|
|
||||||
this.activeView = view as View;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onDragActive = (
|
private onDragActive = (
|
||||||
e: CustomEvent<DragActiveDetail>,
|
e: CustomEvent<DragActiveDetail>,
|
||||||
) => {
|
) => {
|
||||||
@@ -460,7 +438,11 @@ export class AppSidebar extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private navigate(view: View) {
|
private navigate(view: View) {
|
||||||
this.activeView = view;
|
// No optimistic highlight: the shell answers, and it answers
|
||||||
|
// synchronously in `handleNavigate` before it awaits anything.
|
||||||
|
// Setting it here as well is the second opinion this fix
|
||||||
|
// removes -- it is what let a click's highlight survive a
|
||||||
|
// navigation the shell then handled differently.
|
||||||
this.dispatchEvent(new CustomEvent('navigate', {
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
detail: { view },
|
detail: { view },
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ regular/heart
|
|||||||
regular/star
|
regular/star
|
||||||
solid/arrow-down-wide-short
|
solid/arrow-down-wide-short
|
||||||
solid/arrow-left
|
solid/arrow-left
|
||||||
|
solid/arrow-right
|
||||||
solid/arrow-rotate-right
|
solid/arrow-rotate-right
|
||||||
solid/arrows-rotate
|
solid/arrows-rotate
|
||||||
solid/arrow-up-short-wide
|
solid/arrow-up-short-wide
|
||||||
|
|||||||
@@ -400,6 +400,20 @@ async function dispatch(action: string): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The keyboard half of #6. It dispatches the same events the
|
||||||
|
// header's buttons and the detail views' own back buttons do,
|
||||||
|
// rather than calling `history.back()` here: the shell owns the
|
||||||
|
// guard that stops a press at the root leaving the app, and a
|
||||||
|
// second caller reaching for `history` directly is how the old
|
||||||
|
// `navStack` came to disagree with the platform.
|
||||||
|
case 'nav.back':
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate-back'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nav.forward':
|
||||||
|
document.dispatchEvent(new CustomEvent('navigate-forward'));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'nav.queue': {
|
case 'nav.queue': {
|
||||||
const queuePanel = document.getElementById(
|
const queuePanel = document.getElementById(
|
||||||
'queue-panel',
|
'queue-panel',
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
|
|||||||
scope: 'global',
|
scope: 'global',
|
||||||
defaultKey: 'Q',
|
defaultKey: 'Q',
|
||||||
},
|
},
|
||||||
|
'nav.back': {
|
||||||
|
label: 'Back',
|
||||||
|
category: 'Navigation',
|
||||||
|
scope: 'global',
|
||||||
|
defaultKey: 'Alt+Left',
|
||||||
|
},
|
||||||
|
'nav.forward': {
|
||||||
|
label: 'Forward',
|
||||||
|
category: 'Navigation',
|
||||||
|
scope: 'global',
|
||||||
|
defaultKey: 'Alt+Right',
|
||||||
|
},
|
||||||
'app.shortcuts': {
|
'app.shortcuts': {
|
||||||
label: 'Keyboard Shortcuts',
|
label: 'Keyboard Shortcuts',
|
||||||
category: 'App',
|
category: 'App',
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* What the top bar drops when it runs out of room (#143).
|
||||||
|
*
|
||||||
|
* The bar holds five children — the wordmark, back/forward, the library
|
||||||
|
* filter, the search box and the job indicator — and at the bottom of
|
||||||
|
* the Compact band they do not all fit. Measured on `main` at 600×600:
|
||||||
|
* the bar is 611px inside a 600px viewport sitting still, and **862px
|
||||||
|
* while a scan with a long title is running**, because `job-indicator`
|
||||||
|
* is `hidden` when idle and up to 235px wide when it is not. `body` is
|
||||||
|
* `overflow-x: auto`, so what a user sees is a horizontal scrollbar on
|
||||||
|
* a shell that #24 promised would not need one.
|
||||||
|
*
|
||||||
|
* **The fit is measured, never breakpointed**, which is `page-header`'s
|
||||||
|
* rule (#69) and applies here 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 job
|
||||||
|
* indicator by the running job's title, and the search box by its
|
||||||
|
* view-scoped placeholder — so any width you pick is right for exactly
|
||||||
|
* one library, one job and one view. The same sweep that produced the
|
||||||
|
* numbers above found the bar overflowing at every width from 600 to
|
||||||
|
* 899 *and* at 900, where `nav-history` reappears; a breakpoint fixing
|
||||||
|
* "600 to 610" would have fixed the case that happened to be idle.
|
||||||
|
*
|
||||||
|
* **What yields is chosen by #24's own sentence** — *no action is ever
|
||||||
|
* unreachable at any supported size* — which rules out the two cheapest
|
||||||
|
* candidates the issue lists. Hiding the library filter takes away an
|
||||||
|
* action: `library-filter` is the **only** control in the app that sets
|
||||||
|
* the selected library (nothing else calls `setSelectedLibrary`), so
|
||||||
|
* hiding it is trading this promise for the same promise. Collapsing
|
||||||
|
* the search box to an icon is what #57 wants on a phone, but #57 is
|
||||||
|
* blocked behind #62 and building its modal here would be building it
|
||||||
|
* without the thing that blocks it.
|
||||||
|
*
|
||||||
|
* So the two things that yield are the two that are **not** actions and
|
||||||
|
* whose content survives elsewhere:
|
||||||
|
*
|
||||||
|
* 1. **The wordmark**, which is a brand — the window's own title bar
|
||||||
|
* says the same thing, and #48 wants it down to "YJ" at every width
|
||||||
|
* anyway. It yields its *width*, not its existence: the rule in
|
||||||
|
* `index.css` is visually-hidden rather than `display: none`, so the
|
||||||
|
* document keeps its top-level heading.
|
||||||
|
* 2. **The job indicator's label**, leaving the ring. This is not a new
|
||||||
|
* judgement — the component already drops it below 600px for exactly
|
||||||
|
* this reason, and its `sr-only` live region is what announces the
|
||||||
|
* state either way, so nothing is lost to anyone. What a measurement
|
||||||
|
* adds is the band between 600 and 900, where whether the label fits
|
||||||
|
* depends on what else is in the bar rather than on the width alone.
|
||||||
|
*
|
||||||
|
* Measured against the running app with a long-titled scan staged, that
|
||||||
|
* order fits at every width from 320 to 1440 — and collapses nothing at
|
||||||
|
* 320, 390, 599, 899 and 1100, which is the other half of the claim.
|
||||||
|
*
|
||||||
|
* Three things about the mechanism are load-bearing.
|
||||||
|
*
|
||||||
|
* **Every pass starts from all-visible**, so the collapsed set is a
|
||||||
|
* pure function of the current width rather than of how the window got
|
||||||
|
* there. `page-header` states the same rule and the same reasons: a
|
||||||
|
* pass that only ever added would never give the wordmark back, and one
|
||||||
|
* that adjusted by a step would need a hysteresis band to stop it
|
||||||
|
* oscillating on the pixel where it exactly fits.
|
||||||
|
*
|
||||||
|
* **"Fits" is the children against the content box, not `scrollWidth`
|
||||||
|
* against `clientWidth`** — and that distinction is not pedantry, it
|
||||||
|
* is a measured false pass. `scrollWidth` counts a box's *left*
|
||||||
|
* padding and not its right, so with this bar's 2em gutters it
|
||||||
|
* under-reports by 32px: at 700px with a scan running it read
|
||||||
|
* `700/700`, a perfect fit, while `job-indicator` ended 32px past
|
||||||
|
* where the content may go and sat in the gutter. Same family as #69's
|
||||||
|
* title trap, one property over — the measurement that is easiest to
|
||||||
|
* reach for is the one that cannot see the failure. So the predicate
|
||||||
|
* here is the same one `top-bar-fit.spec.ts` asserts: no in-flow child
|
||||||
|
* outside the content box.
|
||||||
|
*
|
||||||
|
* That is only truthful in turn because **nothing here absorbs pressure
|
||||||
|
* by truncating**. The collapsible children are `flex-shrink: 0` in
|
||||||
|
* `index.css`, so a deficit shows up as a child out of bounds instead
|
||||||
|
* of quietly eating the indicator's label, which is `text-overflow:
|
||||||
|
* ellipsis` and would have. The search box is the one child that may
|
||||||
|
* shrink, between its 320px basis and the 200px floor its own
|
||||||
|
* stylesheet sets, and a narrower input hides nothing it was showing.
|
||||||
|
*
|
||||||
|
* **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:
|
||||||
|
* the indicator goes 0 → 235 inside a bar whose width has not changed.
|
||||||
|
* Every element child is observed too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One thing the bar can give up, cheapest first. */
|
||||||
|
interface FitStep {
|
||||||
|
/** For tests and for reading the DOM back. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Applied to the bar; `on` collapses. */
|
||||||
|
readonly collapse: (bar: HTMLElement, on: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order things are given up in. Lowest priority first — see the
|
||||||
|
* argument above for why these two and not the library filter.
|
||||||
|
*/
|
||||||
|
export const FIT_STEPS: readonly FitStep[] = [
|
||||||
|
{
|
||||||
|
id: 'wordmark',
|
||||||
|
collapse: (bar, on) =>
|
||||||
|
bar.querySelector('hgroup')?.classList.toggle('yj-collapsed', on),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'job-label',
|
||||||
|
collapse: (bar, on) =>
|
||||||
|
bar.querySelector('job-indicator')?.toggleAttribute('compact', on),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what the bar shows at its current width.
|
||||||
|
*
|
||||||
|
* Exported for the component tier, which can hand it a bar of known
|
||||||
|
* widths; the app installs the observer below and never calls this.
|
||||||
|
*
|
||||||
|
* @returns the ids collapsed, in the order they were given up.
|
||||||
|
*/
|
||||||
|
export function measureTopBarFit(bar: HTMLElement): string[] {
|
||||||
|
const fits = () => {
|
||||||
|
const style = getComputedStyle(bar);
|
||||||
|
const box = bar.getBoundingClientRect();
|
||||||
|
const left = box.left + parseFloat(style.paddingLeft);
|
||||||
|
const right = box.right - parseFloat(style.paddingRight);
|
||||||
|
|
||||||
|
for (const child of bar.children) {
|
||||||
|
const cs = getComputedStyle(child);
|
||||||
|
|
||||||
|
// Out of flow is out of the question: a collapsed wordmark
|
||||||
|
// is absolutely positioned and 1px wide precisely so that
|
||||||
|
// it costs the row nothing.
|
||||||
|
if (cs.display === 'none' || cs.position === 'absolute') continue;
|
||||||
|
|
||||||
|
const r = child.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Sub-pixel slack: a flex row's widths are fractional and a
|
||||||
|
// rounding difference is not an overflow anyone can see.
|
||||||
|
if (r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const step of FIT_STEPS) step.collapse(bar, false);
|
||||||
|
|
||||||
|
const collapsed: string[] = [];
|
||||||
|
|
||||||
|
if (!fits()) {
|
||||||
|
for (const step of FIT_STEPS) {
|
||||||
|
step.collapse(bar, true);
|
||||||
|
collapsed.push(step.id);
|
||||||
|
|
||||||
|
if (fits()) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the bar and its children, and keep it fitting.
|
||||||
|
*
|
||||||
|
* Returns the uninstaller, which the tests use; the app installs once
|
||||||
|
* for the life of the session.
|
||||||
|
*/
|
||||||
|
export function installTopBarFit(bar: HTMLElement): () => void {
|
||||||
|
let measuring = false;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
// A pass resizes the children it collapses, which the observer
|
||||||
|
// would report back to us. It settles either way — the pass is
|
||||||
|
// idempotent at a given width — but re-entering it is work for
|
||||||
|
// no news, and it is what "ResizeObserver loop completed with
|
||||||
|
// undelivered notifications" is.
|
||||||
|
if (measuring) return;
|
||||||
|
|
||||||
|
measuring = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
measureTopBarFit(bar);
|
||||||
|
} finally {
|
||||||
|
measuring = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
|
||||||
|
observer.observe(bar);
|
||||||
|
|
||||||
|
for (const child of bar.children) observer.observe(child);
|
||||||
|
|
||||||
|
measure();
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
ICON_PLAYLIST,
|
||||||
|
ICON_AUTOTAG,
|
||||||
|
ICON_REQUESTED,
|
||||||
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
|
/** A primary destination. Mirrors `backend/config.View`. */
|
||||||
|
export type View =
|
||||||
|
| 'home'
|
||||||
|
| 'playlists'
|
||||||
|
| 'artists'
|
||||||
|
| 'genres'
|
||||||
|
| 'albums'
|
||||||
|
| 'tracks'
|
||||||
|
| 'explore'
|
||||||
|
| 'downloads'
|
||||||
|
| 'autotag'
|
||||||
|
| 'settings';
|
||||||
|
|
||||||
|
export interface ViewMeta {
|
||||||
|
id: View;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
/**
|
||||||
|
* Views that are never offered as a toggle. Settings alone, because
|
||||||
|
* a user who hides it cannot get back to unhide it.
|
||||||
|
*
|
||||||
|
* This is the *affordance*; the rule is `backend/config.ViewSpec`'s
|
||||||
|
* `Hideable`, which refuses at the setter and drops the key on load.
|
||||||
|
* `config.toml` is hand-editable, so the checkbox being absent is
|
||||||
|
* not what makes this safe — it is only what stops the question
|
||||||
|
* being asked.
|
||||||
|
*/
|
||||||
|
alwaysShown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's primary destinations, in the order the navigation draws
|
||||||
|
* them and Settings lists them.
|
||||||
|
*
|
||||||
|
* It is here rather than inside `app-sidebar` because #25 gave it a
|
||||||
|
* second reader: Settings renders a toggle per view and needs the same
|
||||||
|
* labels in the same order. Same shape as `services/shortcut-meta.ts`,
|
||||||
|
* which moved out of `config-page` for the same reason -- a private
|
||||||
|
* static that two surfaces need is a private static that is about to be
|
||||||
|
* copied.
|
||||||
|
*
|
||||||
|
* The labels and icons deliberately do not exist in Go. Which views
|
||||||
|
* exist and what an unconfigured install shows is `backend/config.Views`
|
||||||
|
* and is asked for over the binding; how they are *drawn* is the
|
||||||
|
* frontend's, and lives beside the rest of the icon vocabulary.
|
||||||
|
*/
|
||||||
|
export const VIEW_META: ViewMeta[] = [
|
||||||
|
{ id: 'home', label: 'Home', icon: 'house' },
|
||||||
|
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
|
||||||
|
{ id: 'artists', label: 'Artists', icon: 'user-group' },
|
||||||
|
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
|
||||||
|
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||||
|
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||||
|
{ id: 'explore', label: 'Explore', icon: 'globe' },
|
||||||
|
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
|
||||||
|
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'gear', alwaysShown: true },
|
||||||
|
];
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Which primary view the app is showing.
|
||||||
|
*
|
||||||
|
* The shell has always known this -- `handleNavigate()` sets
|
||||||
|
* `#main-content`'s `data-active-view` on every path, `_isBack`
|
||||||
|
* included -- and never told anyone. The nav components learned it
|
||||||
|
* from the `navigate` CustomEvent instead, which only the *outbound*
|
||||||
|
* path dispatches: the `popstate` listener calls `handleNavigate()`
|
||||||
|
* directly. So both navs kept highlighting the view you had just left
|
||||||
|
* (#72).
|
||||||
|
*
|
||||||
|
* The fix cannot be a re-dispatch of `navigate`. `index.ts` is itself a
|
||||||
|
* document listener for it, so emitting one from inside
|
||||||
|
* `handleNavigate` is an infinite loop -- and the two statements are
|
||||||
|
* different anyway: `navigate` means *please go to X*, and 28 call
|
||||||
|
* sites across 18 files say it. This says *the active view is now X*,
|
||||||
|
* which only the shell is in a position to say and only once per
|
||||||
|
* navigation.
|
||||||
|
*
|
||||||
|
* Three things about it are load-bearing.
|
||||||
|
*
|
||||||
|
* **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 highlighted
|
||||||
|
* Home -- its `activeView` default, which existed to match the landing
|
||||||
|
* view and matched nothing else ever after. An event has no answer for
|
||||||
|
* a listener that was not there; a value does.
|
||||||
|
*
|
||||||
|
* **A detail view is not a view here.** Opening one leaves the primary
|
||||||
|
* view it was opened from lit, which is what #72 asks for and what
|
||||||
|
* `app-sidebar` used to do by accident -- it guarded on
|
||||||
|
* `navItems.some(...)`, so a name matching no item left its highlight
|
||||||
|
* alone. `bottom-nav` had no such guard and so lit nothing on a detail
|
||||||
|
* view. Neither was correct; the sidebar was stale-but-lucky, and
|
||||||
|
* stating the rule once is what makes the two agree.
|
||||||
|
*
|
||||||
|
* **Whether a view is primary is the shell's fact, not this store's.**
|
||||||
|
* `VIEW_TAGS` in `index.ts` is the list, and a copy of it here is a
|
||||||
|
* second list to forget -- so the caller passes the answer it already
|
||||||
|
* has rather than this file re-deriving it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
class ActiveViewStore {
|
||||||
|
/** Empty until the shell's first navigation, which happens at
|
||||||
|
* startup from `GetDefaultPage()`. Nothing is highlighted for that
|
||||||
|
* moment, which is honest: the alternative is a written-down
|
||||||
|
* default that is right only when the default page agrees with it. */
|
||||||
|
private activeView = '';
|
||||||
|
|
||||||
|
private subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
|
/** The active primary view, e.g. `albums`. */
|
||||||
|
get(): string {
|
||||||
|
return this.activeView;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(view: string): boolean {
|
||||||
|
return this.activeView !== '' && this.activeView === view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the shell on every navigation, `popstate` included.
|
||||||
|
*
|
||||||
|
* `isPrimary` is `view in VIEW_TAGS` at the call site: a detail
|
||||||
|
* view reports itself and deliberately changes nothing, so the view
|
||||||
|
* it was opened from stays lit until the user picks another one.
|
||||||
|
*/
|
||||||
|
setView(view: string, isPrimary: boolean): void {
|
||||||
|
if (!isPrimary) return;
|
||||||
|
if (view === this.activeView) return;
|
||||||
|
|
||||||
|
this.activeView = view;
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(fn: Subscriber): () => void {
|
||||||
|
this.subscribers.add(fn);
|
||||||
|
|
||||||
|
return () => this.subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.subscribers.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeViewStore = new ActiveViewStore();
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
ReactiveController,
|
||||||
|
ReactiveControllerHost,
|
||||||
|
} from 'lit';
|
||||||
|
import { activeViewStore } from '../active-view-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ActiveViewController connects a Lit component to the
|
||||||
|
* ActiveViewStore.
|
||||||
|
*
|
||||||
|
* Usage in a component:
|
||||||
|
*
|
||||||
|
* private activeCtrl = new ActiveViewController(this);
|
||||||
|
*
|
||||||
|
* render() {
|
||||||
|
* const lit = this.activeCtrl.isActive('albums');
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* It reads through to the store rather than copying the value into a
|
||||||
|
* `@state()` field, which is the point of #72: two components holding
|
||||||
|
* their own idea of the active view is what let them disagree with the
|
||||||
|
* shell and with each other.
|
||||||
|
*/
|
||||||
|
export class ActiveViewController implements ReactiveController {
|
||||||
|
private host: ReactiveControllerHost;
|
||||||
|
private unsubscribe?: () => void;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this.host = host;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================================
|
||||||
|
// LIFECYCLE HOOKS
|
||||||
|
// ===============================================================
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
this.unsubscribe = activeViewStore.subscribe(() => {
|
||||||
|
this.host.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================================================
|
||||||
|
// DATA ACCESS
|
||||||
|
// ===============================================================
|
||||||
|
|
||||||
|
/** The active primary view, e.g. `albums`. */
|
||||||
|
get current(): string {
|
||||||
|
return activeViewStore.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(view: string): boolean {
|
||||||
|
return activeViewStore.isActive(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type {
|
||||||
|
ReactiveController,
|
||||||
|
ReactiveControllerHost,
|
||||||
|
} from 'lit';
|
||||||
|
import { historyStore, type HistoryDepth } from '../history-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HistoryController connects a Lit component to the HistoryStore.
|
||||||
|
*
|
||||||
|
* Usage in a component:
|
||||||
|
*
|
||||||
|
* private historyCtrl = new HistoryController(this);
|
||||||
|
*
|
||||||
|
* render() {
|
||||||
|
* const { canBack } = this.historyCtrl.depth;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export class HistoryController implements ReactiveController {
|
||||||
|
private host: ReactiveControllerHost;
|
||||||
|
private unsubscribe?: () => void;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this.host = host;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
this.unsubscribe = historyStore.subscribe(() => {
|
||||||
|
this.host.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
get depth(): HistoryDepth {
|
||||||
|
return historyStore.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
ReactiveController,
|
||||||
|
ReactiveControllerHost,
|
||||||
|
} from 'lit';
|
||||||
|
import { viewVisibilityStore } from '../view-visibility-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewVisibilityController connects a Lit component to the
|
||||||
|
* ViewVisibilityStore.
|
||||||
|
*
|
||||||
|
* It reads through to the store rather than copying the map into a
|
||||||
|
* `@state()` field, for the reason `ActiveViewController` does: there
|
||||||
|
* are two live `<app-sidebar>` instances the moment `bottom-nav`'s
|
||||||
|
* "More" drawer opens, and two components holding their own idea of
|
||||||
|
* which destinations exist is how they come to disagree.
|
||||||
|
*/
|
||||||
|
export class ViewVisibilityController implements ReactiveController {
|
||||||
|
private host: ReactiveControllerHost;
|
||||||
|
private unsubscribe?: () => void;
|
||||||
|
|
||||||
|
constructor(host: ReactiveControllerHost) {
|
||||||
|
this.host = host;
|
||||||
|
host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
this.unsubscribe = viewVisibilityStore.subscribe(() => {
|
||||||
|
this.host.requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
void viewVisibilityStore.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
this.unsubscribe?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the navigation should offer this destination. */
|
||||||
|
visible(view: string): boolean {
|
||||||
|
return viewVisibilityStore.visible(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the config says, ignoring the download-client gate — the
|
||||||
|
* state Settings' own checkbox shows.
|
||||||
|
*/
|
||||||
|
enabled(view: string): boolean {
|
||||||
|
return viewVisibilityStore.enabled(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(view: string, visible: boolean): Promise<void> {
|
||||||
|
return viewVisibilityStore.setVisible(view, visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,8 @@ class DownloadStore {
|
|||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
|
private providersLoaded = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
EventsOn(Events.DownloadProvidersChanged, () => {
|
EventsOn(Events.DownloadProvidersChanged, () => {
|
||||||
void this.refreshProviders();
|
void this.refreshProviders();
|
||||||
@@ -285,6 +287,25 @@ class DownloadStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the providers, and only those, once.
|
||||||
|
*
|
||||||
|
* `init()` additionally fetches the descriptors, the downloads and
|
||||||
|
* the request list, which is right for a page about downloading and
|
||||||
|
* wrong for the sidebar: it only needs `available`, to decide
|
||||||
|
* whether the Downloads destination exists at all (#25), and that
|
||||||
|
* is one query. `DownloadProvidersChanged` keeps it current
|
||||||
|
* afterwards, so configuring a client makes the tab appear without
|
||||||
|
* a restart.
|
||||||
|
*/
|
||||||
|
async ensureProviders(): Promise<void> {
|
||||||
|
if (this.providersLoaded) return;
|
||||||
|
|
||||||
|
this.providersLoaded = true;
|
||||||
|
|
||||||
|
await this.refreshProviders();
|
||||||
|
}
|
||||||
|
|
||||||
async refreshProviders(): Promise<void> {
|
async refreshProviders(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.providersValue = (await ListProviders()) ?? [];
|
this.providersValue = (await ListProviders()) ?? [];
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* How far the session can go back and forward.
|
||||||
|
*
|
||||||
|
* The History API exposes `length` and nothing useful: it counts
|
||||||
|
* entries the app did not push, does not say where in the list the
|
||||||
|
* current entry is, and `popstate` fires *identically* whether the
|
||||||
|
* user went back or forward. So a control that wants to grey itself
|
||||||
|
* out has to be told, and the shell is the only thing in a position to
|
||||||
|
* know (#6).
|
||||||
|
*
|
||||||
|
* Two rules follow from how the shell counts, and both are the reason
|
||||||
|
* this is a pair of booleans rather than one depth:
|
||||||
|
*
|
||||||
|
* **Forward is not "back, negated".** `pushedEntries` -- the counter
|
||||||
|
* this replaces -- decremented on every `popstate`, which made a
|
||||||
|
* forward navigation look like a second back. The shell keeps an index
|
||||||
|
* per entry and a high-water mark instead, and publishes the two
|
||||||
|
* answers rather than the arithmetic.
|
||||||
|
*
|
||||||
|
* **Back stops at the app's own floor.** The launch entry is
|
||||||
|
* *replaced*, not pushed, so that one back press from the root exits
|
||||||
|
* the app on Android; `canBack` is false there, which is what stops
|
||||||
|
* the header's own button being the thing that quits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
export interface HistoryDepth {
|
||||||
|
canBack: boolean;
|
||||||
|
canForward: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryStore {
|
||||||
|
private depth: HistoryDepth = { canBack: false, canForward: false };
|
||||||
|
|
||||||
|
private subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
|
get(): HistoryDepth {
|
||||||
|
return this.depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the shell whenever an entry is pushed or restored. */
|
||||||
|
setDepth(canBack: boolean, canForward: boolean): void {
|
||||||
|
if (
|
||||||
|
canBack === this.depth.canBack &&
|
||||||
|
canForward === this.depth.canForward
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.depth = { canBack, canForward };
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(fn: Subscriber): () => void {
|
||||||
|
this.subscribers.add(fn);
|
||||||
|
|
||||||
|
return () => this.subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.subscribers.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyStore = new HistoryStore();
|
||||||
@@ -9,6 +9,11 @@ export type { ThemeState, BackgroundShade } from './theme-store';
|
|||||||
export { ThemeController } from './controllers/theme-controller';
|
export { ThemeController } from './controllers/theme-controller';
|
||||||
export { searchStore } from './search-store';
|
export { searchStore } from './search-store';
|
||||||
export { SearchController } from './controllers/search-controller';
|
export { SearchController } from './controllers/search-controller';
|
||||||
|
export { activeViewStore } from './active-view-store';
|
||||||
|
export { ActiveViewController } from './controllers/active-view-controller';
|
||||||
|
export { historyStore } from './history-store';
|
||||||
|
export type { HistoryDepth } from './history-store';
|
||||||
|
export { HistoryController } from './controllers/history-controller';
|
||||||
export { shortcutsStore } from './shortcuts-store';
|
export { shortcutsStore } from './shortcuts-store';
|
||||||
export type { ShortcutsState } from './shortcuts-store';
|
export type { ShortcutsState } from './shortcuts-store';
|
||||||
export { ShortcutsController } from './controllers/shortcuts-controller';
|
export { ShortcutsController } from './controllers/shortcuts-controller';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type JobState =
|
|||||||
export type JobKind =
|
export type JobKind =
|
||||||
| 'library-scan'
|
| 'library-scan'
|
||||||
| 'index-build'
|
| 'index-build'
|
||||||
|
| 'download'
|
||||||
| 'autotag-apply'
|
| 'autotag-apply'
|
||||||
| 'catalog-enrich';
|
| 'catalog-enrich';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { EventsOn } from '@runtime/runtime';
|
||||||
|
import { GetViewVisibility, SetViewVisible } from '@go/config/config.js';
|
||||||
|
import { dictByName } from '@utils/binding';
|
||||||
|
import { downloadStore } from './download-store';
|
||||||
|
import { Events } from '../events';
|
||||||
|
|
||||||
|
type Subscriber = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which primary destinations the navigation offers.
|
||||||
|
*
|
||||||
|
* Eleven sidebar entries is more than most libraries need, so #25 makes
|
||||||
|
* them individually toggleable. Three rules about this are load-bearing.
|
||||||
|
*
|
||||||
|
* **Hidden is not unreachable.** This decides what the *nav* draws and
|
||||||
|
* nothing else: `navigate` still resolves a hidden view, which is not a
|
||||||
|
* nicety — detail views navigate into these, and the shell's launch
|
||||||
|
* page is one of them. Nothing here needs a special case for the
|
||||||
|
* highlight either, because #72 moved that onto `active-view-store`:
|
||||||
|
* `app-sidebar` asks `isActive(id)` per *rendered* item, so a hidden
|
||||||
|
* view lights nothing exactly as a detail view does.
|
||||||
|
*
|
||||||
|
* **The defaults live in Go**, in `backend/config.Views`, and this asks
|
||||||
|
* for the *resolved* answer rather than the stored map. A config that
|
||||||
|
* says nothing about a view means "that view's own default", so a copy
|
||||||
|
* of the defaults here would be a second thing to keep in step — and
|
||||||
|
* the one that shipped in the artifact, not the one being edited.
|
||||||
|
*
|
||||||
|
* **Downloads is a second question**, answered by the download client
|
||||||
|
* rather than by the config: a destination for a feature that cannot
|
||||||
|
* work is worse than an absent one. It is gated at `visible()` and not
|
||||||
|
* in the config, so switching it on in Settings still means what it
|
||||||
|
* says once a client exists. `available` is false until the providers
|
||||||
|
* have loaded, which makes the tab *appear* on a fresh launch rather
|
||||||
|
* than appearing and then vanishing — the less jarring half of a race
|
||||||
|
* that resolves in one query.
|
||||||
|
*/
|
||||||
|
class ViewVisibilityStore {
|
||||||
|
/** The backend's resolved answer, empty until the first load. */
|
||||||
|
private configured: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
private subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
EventsOn(Events.GeneralConfigChanged, () => {
|
||||||
|
void this.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A client configured later has to add the destination without a
|
||||||
|
// restart -- #37's rule, one surface over.
|
||||||
|
downloadStore.subscribe(() => this.notify());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the visibility map once. Safe to call from every mount. */
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.loaded) return;
|
||||||
|
|
||||||
|
this.loaded = true;
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.refresh(),
|
||||||
|
downloadStore.ensureProviders(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the navigation should offer this destination.
|
||||||
|
*
|
||||||
|
* An unknown id is visible: the caller is drawing it from its own
|
||||||
|
* list, and a view this store has not heard of (or has not loaded
|
||||||
|
* yet) is better shown than silently dropped.
|
||||||
|
*/
|
||||||
|
visible(view: string): boolean {
|
||||||
|
if (view === 'downloads' && !downloadStore.available) return false;
|
||||||
|
|
||||||
|
return this.configured[view] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the *config* says, ignoring the download-client gate — which
|
||||||
|
* is what Settings' own checkbox has to show, or a user with no
|
||||||
|
* client would see Downloads switched off and be unable to switch
|
||||||
|
* it on.
|
||||||
|
*/
|
||||||
|
enabled(view: string): boolean {
|
||||||
|
return this.configured[view] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVisible(view: string, visible: boolean): Promise<void> {
|
||||||
|
await SetViewVisible(view, visible);
|
||||||
|
|
||||||
|
// The backend emits GeneralConfigChanged, but the caller is
|
||||||
|
// owed the new state by the time this resolves.
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(fn: Subscriber): () => void {
|
||||||
|
this.subscribers.add(fn);
|
||||||
|
|
||||||
|
return () => this.subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.configured = await dictByName(GetViewVisibility());
|
||||||
|
this.notify();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load view visibility:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
this.subscribers.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewVisibilityStore = new ViewVisibilityStore();
|
||||||
@@ -3,15 +3,21 @@
|
|||||||
*
|
*
|
||||||
* Three of these are about the thing that makes a second nav dangerous:
|
* Three of these are about the thing that makes a second nav dangerous:
|
||||||
* it has to agree with the first one. `bottom-nav` emits the same
|
* it has to agree with the first one. `bottom-nav` emits the same
|
||||||
* bubbling, composed `navigate` event `app-sidebar` does and listens
|
* bubbling, composed `navigate` event `app-sidebar` does, and reads
|
||||||
* for that event globally, so a navigation from anywhere — a card, a
|
* which tab is lit from `activeViewStore` — the shell's one statement
|
||||||
* detail view, the drawer's own sidebar — moves its highlight too. A
|
* of where the user is — so it follows a navigation from anywhere: a
|
||||||
* tab bar that only tracks its own clicks looks right until the moment
|
* card, a detail view, the drawer's own sidebar, or the back gesture.
|
||||||
* the user arrives somewhere by another route.
|
*
|
||||||
|
* That last one is why the source is the store and not the `navigate`
|
||||||
|
* event these tests used to dispatch. `popstate` dispatches no
|
||||||
|
* `navigate` (index.ts calls `handleNavigate` directly), so a tab bar
|
||||||
|
* listening for the event looked right until the user pressed back —
|
||||||
|
* #72.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, beforeEach } from 'vitest';
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
import '@components/bottom-nav/bottom-nav';
|
import '@components/bottom-nav/bottom-nav';
|
||||||
|
import { activeViewStore } from '@store/active-view-store';
|
||||||
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
|
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
|
||||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
import { resetHarness } from '@test/support/harness';
|
import { resetHarness } from '@test/support/harness';
|
||||||
@@ -21,6 +27,12 @@ type Nav = BottomNav;
|
|||||||
const tabs = (el: HTMLElement) =>
|
const tabs = (el: HTMLElement) =>
|
||||||
shadowAll<HTMLButtonElement>(el, 'nav button');
|
shadowAll<HTMLButtonElement>(el, 'nav button');
|
||||||
|
|
||||||
|
/** The testids of whatever the bar says is the current page. */
|
||||||
|
const current = (el: HTMLElement) =>
|
||||||
|
tabs(el)
|
||||||
|
.filter((b) => b.getAttribute('aria-current') === 'page')
|
||||||
|
.map((b) => b.dataset.testid);
|
||||||
|
|
||||||
/** Resolve on one occurrence of an event, or reject loudly on time. */
|
/** Resolve on one occurrence of an event, or reject loudly on time. */
|
||||||
const once = (el: Element, name: string, timeoutMs = 2000) =>
|
const once = (el: Element, name: string, timeoutMs = 2000) =>
|
||||||
new Promise<void>((resolve, reject) => {
|
new Promise<void>((resolve, reject) => {
|
||||||
@@ -70,36 +82,55 @@ describe('bottom-nav', () => {
|
|||||||
it('follows a navigation it did not send', async () => {
|
it('follows a navigation it did not send', async () => {
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('navigate', {
|
activeViewStore.setView('tracks', true);
|
||||||
detail: { view: 'tracks' },
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}));
|
|
||||||
await update(el, {});
|
await update(el, {});
|
||||||
|
|
||||||
const current = tabs(el)
|
expect(current(el)).toEqual(['tab-tracks']);
|
||||||
.filter((b) => b.getAttribute('aria-current') === 'page')
|
|
||||||
.map((b) => b.dataset.testid);
|
|
||||||
|
|
||||||
expect(current).toEqual(['tab-tracks']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
|
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
|
||||||
const el = await fixture<Nav>('bottom-nav');
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
document.dispatchEvent(new CustomEvent('navigate', {
|
activeViewStore.setView('settings', true);
|
||||||
detail: { view: 'settings' },
|
|
||||||
bubbles: true,
|
|
||||||
composed: true,
|
|
||||||
}));
|
|
||||||
await update(el, {});
|
await update(el, {});
|
||||||
|
|
||||||
// Settings lives in the drawer, so nothing in the bar is current.
|
// Settings lives in the drawer, so nothing in the bar is current.
|
||||||
// Leaving Home highlighted would be a tab bar lying about where
|
// Leaving Home highlighted would be a tab bar lying about where
|
||||||
// the user is.
|
// the user is.
|
||||||
expect(
|
expect(current(el)).toEqual([]);
|
||||||
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
|
});
|
||||||
).toHaveLength(0);
|
|
||||||
|
it('keeps the parent tab lit while a detail view is open', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
// A detail view reports itself and is not primary, so it changes
|
||||||
|
// nothing. This is the first half of #72: the bar used to take the
|
||||||
|
// name, match it against no tab, and light nothing at all — while
|
||||||
|
// `app-sidebar`, which guarded on its own item list, kept the
|
||||||
|
// highlight. Neither was deliberate and the two disagreed.
|
||||||
|
activeViewStore.setView('explore-album-details', false);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
expect(current(el)).toEqual(['tab-albums']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the back path, which dispatches no navigate event', async () => {
|
||||||
|
const el = await fixture<Nav>('bottom-nav');
|
||||||
|
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
activeViewStore.setView('tracks', true);
|
||||||
|
await update(el, {});
|
||||||
|
expect(current(el)).toEqual(['tab-tracks']);
|
||||||
|
|
||||||
|
// What `popstate` does: the shell replays the entry through
|
||||||
|
// `handleNavigate` without dispatching `navigate`. A bar listening
|
||||||
|
// for the event stayed on Tracks — the view just left, confidently
|
||||||
|
// wrong rather than merely blank.
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
expect(current(el)).toEqual(['tab-albums']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the drawer when a navigation happens', async () => {
|
it('closes the drawer when a navigation happens', async () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '@components/sidebar/app-sidebar';
|
|||||||
import '@components/library-filter/library-filter';
|
import '@components/library-filter/library-filter';
|
||||||
import '@components/library-status-indicator/library-status-indicator';
|
import '@components/library-status-indicator/library-status-indicator';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
|
import { activeViewStore } from '@store/active-view-store';
|
||||||
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
|
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
|
||||||
import {
|
import {
|
||||||
fixture,
|
fixture,
|
||||||
@@ -25,7 +26,26 @@ import {
|
|||||||
ICON_REQUESTED,
|
ICON_REQUESTED,
|
||||||
} from '@utils/icon-language';
|
} from '@utils/icon-language';
|
||||||
|
|
||||||
|
/** A configured, enabled download client. */
|
||||||
|
const PROVIDER = {
|
||||||
|
id: 1,
|
||||||
|
kind: 'slskd',
|
||||||
|
name: 'Sound',
|
||||||
|
enabled: true,
|
||||||
|
priority: 50,
|
||||||
|
};
|
||||||
|
|
||||||
describe('<app-sidebar>', () => {
|
describe('<app-sidebar>', () => {
|
||||||
|
// Downloads is offered only where there is a client to download with
|
||||||
|
// (#25), so "all eleven destinations" is a statement about a
|
||||||
|
// configured install. `view-visibility.test.ts` owns the rule itself;
|
||||||
|
// this states the world these cases are describing.
|
||||||
|
beforeEach(async () => {
|
||||||
|
stub('download.Service.ListProviders', [PROVIDER]);
|
||||||
|
emit(Events.DownloadProvidersChanged);
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders a testid per destination, which is how e2e navigates', async () => {
|
it('renders a testid per destination, which is how e2e navigates', async () => {
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
@@ -43,12 +63,13 @@ describe('<app-sidebar>', () => {
|
|||||||
'nav-explore',
|
'nav-explore',
|
||||||
'nav-downloads',
|
'nav-downloads',
|
||||||
'nav-autotag',
|
'nav-autotag',
|
||||||
'nav-jobs',
|
|
||||||
'nav-settings',
|
'nav-settings',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks exactly one item as the current page', async () => {
|
it('marks exactly one item as the current page', async () => {
|
||||||
|
activeViewStore.setView('home', true);
|
||||||
|
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
const current = shadowAll(el, 'li button').filter(
|
const current = shadowAll(el, 'li button').filter(
|
||||||
@@ -72,17 +93,49 @@ describe('<app-sidebar>', () => {
|
|||||||
expect(seen).toEqual(['artists']);
|
expect(seen).toEqual(['artists']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves aria-current to the clicked destination', async () => {
|
it('moves aria-current with the shell, not with the click', async () => {
|
||||||
|
activeViewStore.setView('home', true);
|
||||||
|
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
|
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
|
||||||
await el.updateComplete;
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// The click asks; it does not answer. The sidebar used to move its
|
||||||
|
// own highlight optimistically, which is the second opinion #72
|
||||||
|
// removed -- one component deciding where the user is, while the
|
||||||
|
// shell decided separately and `bottom-nav` decided a third way.
|
||||||
|
expect(
|
||||||
|
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
|
||||||
|
).toBe('false');
|
||||||
|
|
||||||
|
// What the shell does with that event, in one line.
|
||||||
|
activeViewStore.setView('genres', true);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
|
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
|
||||||
).toBe('page');
|
).toBe('page');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('follows the back path, which dispatches no navigate event', async () => {
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
|
||||||
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
|
// `popstate` replays an entry through `handleNavigate` directly, so
|
||||||
|
// there is no `navigate` event to hear -- which is why the sidebar
|
||||||
|
// stayed on the view the user had just left (#72).
|
||||||
|
activeViewStore.setView('tracks', true);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shadowAll(el, 'li button')
|
||||||
|
.filter((item) => item.getAttribute('aria-current') === 'page')
|
||||||
|
.map((item) => item.getAttribute('data-testid')),
|
||||||
|
).toEqual(['nav-tracks']);
|
||||||
|
});
|
||||||
|
|
||||||
it('looks the way it did last time', async () => {
|
it('looks the way it did last time', async () => {
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Background work, shown where the work is started (#27).
|
||||||
|
*
|
||||||
|
* The Jobs tab is gone; each kind's rows now live beside the thing that
|
||||||
|
* starts it — scans in Settings → Libraries, index work in Search
|
||||||
|
* Index, downloads under the download clients, the autotag apply in the
|
||||||
|
* Autotag view. What those four surfaces never had, and what this
|
||||||
|
* carries to them, is the *generic* affordances: pause, cancel,
|
||||||
|
* "Details", and a finished job you can dismiss.
|
||||||
|
*
|
||||||
|
* The assertions are about which rows a panel owns and what its buttons
|
||||||
|
* do, not about the store — `job-store` already has the snapshot
|
||||||
|
* covered, and a panel that renders the right rows for the wrong reason
|
||||||
|
* would pass either way.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/jobs/job-panel';
|
||||||
|
import { emit, flush, lastArgs, calls, resetHarness, stub } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
/** A job snapshot entry, with the fields `job-row` actually reads. */
|
||||||
|
const job = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'scan:1',
|
||||||
|
kind: 'library-scan',
|
||||||
|
title: 'Scanning Music',
|
||||||
|
state: 'running',
|
||||||
|
current: 3,
|
||||||
|
total: 10,
|
||||||
|
caps: { pausable: true, cancellable: true },
|
||||||
|
stages: null,
|
||||||
|
stats: null,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
logCount: 0,
|
||||||
|
warnCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Push a full snapshot, which is what the backend emits. */
|
||||||
|
async function snapshot(jobs: unknown[]): Promise<void> {
|
||||||
|
emit(Events.JobsChanged, jobs);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (el: HTMLElement) => shadowAll(el, 'job-row');
|
||||||
|
|
||||||
|
const titles = (el: HTMLElement) =>
|
||||||
|
rows(el).map((row) => (row as HTMLElement & { job: { title: string } }).job.title);
|
||||||
|
|
||||||
|
describe('<job-panel>', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
stub('jobs.Service.GetJobs', []);
|
||||||
|
await snapshot([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders only the kinds it was asked for', async () => {
|
||||||
|
const el = await fixture<LitElement>('job-panel', {
|
||||||
|
kinds: 'index-build,catalog-enrich',
|
||||||
|
});
|
||||||
|
|
||||||
|
await snapshot([
|
||||||
|
job({ id: 'scan:1', kind: 'library-scan', title: 'Scanning Music' }),
|
||||||
|
job({ id: 'idx', kind: 'index-build', title: 'Building the index' }),
|
||||||
|
job({ id: 'enrich', kind: 'catalog-enrich', title: 'Filling in artists' }),
|
||||||
|
]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// The title is inside `job-row`'s own shadow root, so this asks
|
||||||
|
// the rows what they are drawing rather than reading the panel's
|
||||||
|
// text -- which would pass whether or not a row rendered.
|
||||||
|
expect(titles(el)).toEqual(['Building the index', 'Filling in artists']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An idle panel in four places is four pieces of furniture describing
|
||||||
|
* an absence — and `hidden` rather than an empty render, because the
|
||||||
|
* host's own margin would otherwise still be spent.
|
||||||
|
*/
|
||||||
|
it('takes up no room when it has nothing to say', async () => {
|
||||||
|
const el = await fixture<LitElement>('job-panel', { kinds: 'download' });
|
||||||
|
|
||||||
|
await snapshot([job({ id: 'scan:1', kind: 'library-scan' })]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.hidden).toBe(true);
|
||||||
|
expect(rows(el)).toHaveLength(0);
|
||||||
|
|
||||||
|
await snapshot([
|
||||||
|
job({ id: 'dl:1', kind: 'download', title: 'Downloading Glass Harbour' }),
|
||||||
|
]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(el.hidden).toBe(false);
|
||||||
|
expect(rows(el)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controls go through `applyJobControl`, which is what carries
|
||||||
|
* the index build's "you will discard hours of downloading"
|
||||||
|
* confirmation across this move. A host drawing its own buttons would
|
||||||
|
* have dropped it silently.
|
||||||
|
*/
|
||||||
|
it('pauses through the shared handler', async () => {
|
||||||
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job()]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Pause"]')?.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(lastArgs('jobs.Service.PauseJob')).toEqual(['scan:1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelling an index build asks first; cancelling a scan does not,
|
||||||
|
* because a scan is cheap to re-run. Both answers live in
|
||||||
|
* `applyJobControl` and both had to survive the move.
|
||||||
|
*/
|
||||||
|
it('does not ask before cancelling a scan', async () => {
|
||||||
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job()]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Stop"]')?.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(lastArgs('jobs.Service.CancelJob')).toEqual(['scan:1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A finished job is dismissed one at a time. There is deliberately no
|
||||||
|
* "Clear finished" here: `ClearFinishedJobs` is global, so a Clear in
|
||||||
|
* the Libraries panel would discard the index build's history too.
|
||||||
|
*/
|
||||||
|
it('keeps finished jobs, dismissible one by one', async () => {
|
||||||
|
const el = await fixture<LitElement>('job-panel', { kinds: 'library-scan' });
|
||||||
|
|
||||||
|
await snapshot([job({ state: 'complete' })]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const row = rows(el)[0]!;
|
||||||
|
|
||||||
|
shadow<HTMLButtonElement>(row, 'button[aria-label^="Dismiss"]')?.click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(lastArgs('jobs.Service.DismissJob')).toEqual(['scan:1']);
|
||||||
|
expect(calls('jobs.Service.ClearFinishedJobs')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,8 @@ import { describe, expect, it, beforeEach } from 'vitest';
|
|||||||
import '@components/sidebar/app-sidebar';
|
import '@components/sidebar/app-sidebar';
|
||||||
import '@components/queue-panel/queue-panel';
|
import '@components/queue-panel/queue-panel';
|
||||||
import '@components/track-list/track-list';
|
import '@components/track-list/track-list';
|
||||||
import { stub } from '@test/support/harness';
|
import { stub, emit, flush } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
|
||||||
/** Two fixture tracks, enough to move a focus ring between. */
|
/** Two fixture tracks, enough to move a focus ring between. */
|
||||||
@@ -33,12 +34,22 @@ const TRACKS = [
|
|||||||
] as never[];
|
] as never[];
|
||||||
|
|
||||||
describe('<app-sidebar> is reachable', () => {
|
describe('<app-sidebar> is reachable', () => {
|
||||||
|
// Eleven destinations assumes a configured download client, since
|
||||||
|
// Downloads is not offered without one (#25).
|
||||||
|
beforeEach(async () => {
|
||||||
|
stub('download.Service.ListProviders', [
|
||||||
|
{ id: 1, kind: 'slskd', name: 'Sound', enabled: true, priority: 50 },
|
||||||
|
]);
|
||||||
|
emit(Events.DownloadProvidersChanged);
|
||||||
|
await flush();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders every destination as a button, not a bare list item', async () => {
|
it('renders every destination as a button, not a bare list item', async () => {
|
||||||
const el = await fixture('app-sidebar');
|
const el = await fixture('app-sidebar');
|
||||||
|
|
||||||
const items = shadowAll(el, 'li button');
|
const items = shadowAll(el, 'li button');
|
||||||
|
|
||||||
expect(items).toHaveLength(11);
|
expect(items).toHaveLength(10);
|
||||||
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
|
expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* The global back/forward control (#6).
|
||||||
|
*
|
||||||
|
* The interesting half of this component is what it does when it
|
||||||
|
* *cannot* act. The app's rule is that a control which cannot do
|
||||||
|
* anything should not be a button at all — `library-status-indicator`
|
||||||
|
* spent a release as a `<button>` whose handler was a comment — and
|
||||||
|
* this is the documented exception: back and forward are a pair whose
|
||||||
|
* positions the user learns, so the unavailable one greys out rather
|
||||||
|
* than disappearing and moving the other one under the cursor.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/nav-history/nav-history';
|
||||||
|
import { fixture, shadow, update } from '@test/support/render';
|
||||||
|
import { historyStore } from '@store/history-store';
|
||||||
|
|
||||||
|
const back = (el: HTMLElement) =>
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="history-back"]');
|
||||||
|
|
||||||
|
const forward = (el: HTMLElement) =>
|
||||||
|
shadow<HTMLButtonElement>(el, '[data-testid="history-forward"]');
|
||||||
|
|
||||||
|
describe('nav-history', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
historyStore.setDepth(false, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers both directions, named', async () => {
|
||||||
|
const el = await fixture('nav-history');
|
||||||
|
|
||||||
|
// The name is the whole control: two arrows side by side are
|
||||||
|
// indistinguishable to anything not looking at them.
|
||||||
|
expect(back(el)?.getAttribute('aria-label')).toBe('Back');
|
||||||
|
expect(forward(el)?.getAttribute('aria-label')).toBe('Forward');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables what cannot be done, in both directions independently', async () => {
|
||||||
|
const el = await fixture('nav-history');
|
||||||
|
|
||||||
|
expect(back(el)?.disabled).toBe(true);
|
||||||
|
expect(forward(el)?.disabled).toBe(true);
|
||||||
|
|
||||||
|
historyStore.setDepth(true, false);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
expect(back(el)?.disabled).toBe(false);
|
||||||
|
expect(forward(el)?.disabled).toBe(true);
|
||||||
|
|
||||||
|
// Standing in the middle of the list, which is what a back press
|
||||||
|
// followed by a look at the toolbar produces.
|
||||||
|
historyStore.setDepth(true, true);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
expect(back(el)?.disabled).toBe(false);
|
||||||
|
expect(forward(el)?.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the shell rather than reaching for history itself', async () => {
|
||||||
|
const el = await fixture('nav-history');
|
||||||
|
const seen: string[] = [];
|
||||||
|
|
||||||
|
for (const name of ['navigate-back', 'navigate-forward']) {
|
||||||
|
document.addEventListener(name, () => seen.push(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
historyStore.setDepth(true, true);
|
||||||
|
await update(el, {});
|
||||||
|
|
||||||
|
back(el)?.click();
|
||||||
|
forward(el)?.click();
|
||||||
|
|
||||||
|
// Composed and bubbling, or index.ts's document listener — which
|
||||||
|
// owns the guard that stops a press at the root leaving the app —
|
||||||
|
// never hears them. A second caller reaching for `history`
|
||||||
|
// directly is how the old `navStack` came to disagree with the
|
||||||
|
// platform.
|
||||||
|
expect(seen).toEqual(['navigate-back', 'navigate-forward']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when it cannot act', async () => {
|
||||||
|
const el = await fixture('nav-history');
|
||||||
|
const seen: string[] = [];
|
||||||
|
|
||||||
|
document.addEventListener('navigate-back', () => seen.push('back'));
|
||||||
|
|
||||||
|
back(el)?.click();
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,7 +40,7 @@ import '@components/jobs/job-details-drawer';
|
|||||||
import '@components/jobs/job-indicator';
|
import '@components/jobs/job-indicator';
|
||||||
import '@components/jobs/job-log-view';
|
import '@components/jobs/job-log-view';
|
||||||
import '@components/jobs/job-row';
|
import '@components/jobs/job-row';
|
||||||
import '@components/jobs/jobs-view';
|
import '@components/jobs/job-panel';
|
||||||
import '@components/library-filter/library-filter';
|
import '@components/library-filter/library-filter';
|
||||||
import '@components/library-status-indicator/library-status-indicator';
|
import '@components/library-status-indicator/library-status-indicator';
|
||||||
import '@components/now-playing/now-playing';
|
import '@components/now-playing/now-playing';
|
||||||
@@ -87,8 +87,8 @@ const TAGS = [
|
|||||||
'job-details-drawer',
|
'job-details-drawer',
|
||||||
'job-indicator',
|
'job-indicator',
|
||||||
'job-log-view',
|
'job-log-view',
|
||||||
|
'job-panel',
|
||||||
'job-row',
|
'job-row',
|
||||||
'jobs-view',
|
|
||||||
'library-filter',
|
'library-filter',
|
||||||
'library-status-indicator',
|
'library-status-indicator',
|
||||||
'now-playing',
|
'now-playing',
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import '@components/explore-view/explore-view';
|
|||||||
import '@components/home-view/home-view';
|
import '@components/home-view/home-view';
|
||||||
import '@components/downloads-view/downloads-view';
|
import '@components/downloads-view/downloads-view';
|
||||||
|
|
||||||
import '@components/jobs/jobs-view';
|
|
||||||
import '@components/playlist-view/playlist-view';
|
import '@components/playlist-view/playlist-view';
|
||||||
import { fixture } from '@test/support/render';
|
import { fixture } from '@test/support/render';
|
||||||
import { stub, flush } from '@test/support/harness';
|
import { stub, flush } from '@test/support/harness';
|
||||||
@@ -223,7 +222,6 @@ const CACHED_VIEWS = [
|
|||||||
'artists-view',
|
'artists-view',
|
||||||
'genres-view',
|
'genres-view',
|
||||||
'downloads-view',
|
'downloads-view',
|
||||||
'jobs-view',
|
|
||||||
'playlist-view',
|
'playlist-view',
|
||||||
'explore-view',
|
'explore-view',
|
||||||
'home-view',
|
'home-view',
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Which destinations the navigation offers (#25).
|
||||||
|
*
|
||||||
|
* Eleven sidebar entries is more than most libraries need, so they are
|
||||||
|
* individually toggleable. The assertions here are about the **nav**
|
||||||
|
* and not about the setting being saved: "the config was written" is
|
||||||
|
* the plumbing, and a spec that measures the plumbing is how #69 and
|
||||||
|
* #72 both shipped green on a broken build.
|
||||||
|
*
|
||||||
|
* Two singletons make ordering matter, and both are driven the way the
|
||||||
|
* app drives them rather than reset: `GeneralConfigChanged` is what the
|
||||||
|
* backend emits when a toggle is saved, and `DownloadProvidersChanged`
|
||||||
|
* is what it emits when a client is configured. So each case states the
|
||||||
|
* world it wants and is independent of which one ran first.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/sidebar/app-sidebar';
|
||||||
|
import '@components/bottom-nav/bottom-nav';
|
||||||
|
import { activeViewStore } from '@store/active-view-store';
|
||||||
|
import { stub, emit, flush, resetHarness } from '@test/support/harness';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
const PROVIDER = {
|
||||||
|
id: 1,
|
||||||
|
kind: 'slskd',
|
||||||
|
name: 'Sound',
|
||||||
|
enabled: true,
|
||||||
|
priority: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every view id the sidebar is currently drawing, in order. */
|
||||||
|
const navIDs = (el: HTMLElement) =>
|
||||||
|
shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||||
|
.map((b) => b.dataset.testid?.replace(/^nav-/, ''))
|
||||||
|
.filter((id): id is string => id !== undefined);
|
||||||
|
|
||||||
|
const tabIDs = (el: HTMLElement) =>
|
||||||
|
shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||||
|
.map((b) => b.dataset.testid?.replace(/^tab-/, ''))
|
||||||
|
.filter((id): id is string => id !== undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State the backend's resolved answer and push the event that says it
|
||||||
|
* changed. The map is *resolved* — every known view, defaults already
|
||||||
|
* applied — because that is what the binding returns and the whole
|
||||||
|
* reason the frontend holds no copy of the defaults.
|
||||||
|
*/
|
||||||
|
async function setViews(views: Record<string, boolean>): Promise<void> {
|
||||||
|
stub('config.Config.GetViewVisibility', views);
|
||||||
|
emit(Events.GeneralConfigChanged, {});
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setClientConfigured(configured: boolean): Promise<void> {
|
||||||
|
stub('download.Service.ListProviders', configured ? [PROVIDER] : []);
|
||||||
|
emit(Events.DownloadProvidersChanged);
|
||||||
|
await flush();
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_VISIBLE = {
|
||||||
|
home: true,
|
||||||
|
playlists: true,
|
||||||
|
artists: true,
|
||||||
|
genres: true,
|
||||||
|
albums: true,
|
||||||
|
tracks: true,
|
||||||
|
explore: true,
|
||||||
|
downloads: true,
|
||||||
|
autotag: true,
|
||||||
|
settings: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('view visibility', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetHarness();
|
||||||
|
await setViews(ALL_VISIBLE);
|
||||||
|
await setClientConfigured(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws every destination the config keeps', async () => {
|
||||||
|
const el = await fixture<LitElement>('app-sidebar');
|
||||||
|
|
||||||
|
expect(navIDs(el)).toEqual([
|
||||||
|
'home',
|
||||||
|
'playlists',
|
||||||
|
'artists',
|
||||||
|
'genres',
|
||||||
|
'albums',
|
||||||
|
'tracks',
|
||||||
|
'explore',
|
||||||
|
'downloads',
|
||||||
|
'autotag',
|
||||||
|
'settings',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the ones the user switched off', async () => {
|
||||||
|
const el = await fixture<LitElement>('app-sidebar');
|
||||||
|
|
||||||
|
await setViews({ ...ALL_VISIBLE, autotag: false, explore: false });
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(navIDs(el)).not.toContain('autotag');
|
||||||
|
expect(navIDs(el)).not.toContain('explore');
|
||||||
|
expect(navIDs(el)).toContain('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hiding is about the nav item, not about the view. Detail views
|
||||||
|
* navigate into these and the launch page is one of them, so the
|
||||||
|
* shell's own statement of where the user is has to survive a
|
||||||
|
* destination that draws no item — and it does so with no special
|
||||||
|
* case here, because #72 moved the highlight onto `active-view-store`
|
||||||
|
* and this only filters what is rendered.
|
||||||
|
*/
|
||||||
|
it('lights nothing when the active view is a hidden one', async () => {
|
||||||
|
const el = await fixture<LitElement>('app-sidebar');
|
||||||
|
|
||||||
|
await setViews({ ...ALL_VISIBLE, autotag: false });
|
||||||
|
activeViewStore.setView('autotag', true);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const lit = shadowAll<HTMLButtonElement>(el, 'nav button')
|
||||||
|
.filter((b) => b.getAttribute('aria-current') === 'page');
|
||||||
|
|
||||||
|
expect(lit).toHaveLength(0);
|
||||||
|
expect(navIDs(el)).not.toContain('autotag');
|
||||||
|
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A destination for a feature that cannot work is worse than an
|
||||||
|
* absent one, so Downloads asks the download client rather than the
|
||||||
|
* config — and it appears when one is configured, without a restart
|
||||||
|
* (#37's rule, one surface over).
|
||||||
|
*/
|
||||||
|
it('hides Downloads until a client is configured', async () => {
|
||||||
|
const el = await fixture<LitElement>('app-sidebar');
|
||||||
|
|
||||||
|
await setClientConfigured(false);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(navIDs(el)).not.toContain('downloads');
|
||||||
|
|
||||||
|
await setClientConfigured(true);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(navIDs(el)).toContain('downloads');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab bar honours the toggles too, and the reason is local: its
|
||||||
|
* "More" drawer opens the same `<app-sidebar>`, which filters. An
|
||||||
|
* unfiltered bar would contradict its own drawer one tap away.
|
||||||
|
*/
|
||||||
|
it('drops a hidden destination from the phone tab bar', async () => {
|
||||||
|
const el = await fixture<LitElement>('bottom-nav');
|
||||||
|
|
||||||
|
expect(tabIDs(el)).toEqual(['home', 'albums', 'tracks', 'playlists', 'more']);
|
||||||
|
|
||||||
|
await setViews({ ...ALL_VISIBLE, albums: false });
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(tabIDs(el)).toEqual(['home', 'tracks', 'playlists', 'more']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** "More" is not a destination and is never filtered away: it is how
|
||||||
|
* everything else is still reachable. */
|
||||||
|
it('keeps More when every tab is hidden', async () => {
|
||||||
|
const el = await fixture<LitElement>('bottom-nav');
|
||||||
|
|
||||||
|
await setViews({
|
||||||
|
...ALL_VISIBLE,
|
||||||
|
home: false,
|
||||||
|
albums: false,
|
||||||
|
tracks: false,
|
||||||
|
playlists: false,
|
||||||
|
});
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(tabIDs(el)).toEqual(['more']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* The three small stores behind view chrome: the global search term,
|
* The small stores behind view chrome: the global search term, the
|
||||||
* the track list's column set, and the explore cache that keeps detail
|
* active view both navs highlight, the track list's column set, and
|
||||||
* pages from re-fetching what a search already returned.
|
* the explore cache that keeps detail pages from re-fetching what a
|
||||||
|
* search already returned.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it, beforeEach } from 'vitest';
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
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 { trackListStore } from '@store/tracklist-store';
|
import { trackListStore } from '@store/tracklist-store';
|
||||||
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
|
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
@@ -80,6 +83,97 @@ describe('search store', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('active view store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
activeViewStore.setView('home', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the primary view the shell navigated to', () => {
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
|
||||||
|
expect(activeViewStore.get()).toBe('albums');
|
||||||
|
expect(activeViewStore.isActive('albums')).toBe(true);
|
||||||
|
expect(activeViewStore.isActive('tracks')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the primary view lit while a detail view is open', () => {
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
activeViewStore.setView('explore-album-details', false);
|
||||||
|
|
||||||
|
// #72's third finding, made deliberate: a detail view is not a
|
||||||
|
// destination in either nav, and the tab it was opened from is
|
||||||
|
// where the user still is. `app-sidebar` did this by accident (it
|
||||||
|
// guarded on its own item list) and `bottom-nav` did not do it at
|
||||||
|
// all, which is why one looked right and the other looked broken.
|
||||||
|
expect(activeViewStore.get()).toBe('albums');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when the view is unchanged', () => {
|
||||||
|
let notifications = 0;
|
||||||
|
const off = activeViewStore.subscribe(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
activeViewStore.setView('albums', true);
|
||||||
|
activeViewStore.setView('explore-album-details', false);
|
||||||
|
off();
|
||||||
|
|
||||||
|
expect(notifications).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights nothing for a view with no name', () => {
|
||||||
|
// The store starts empty rather than defaulting to a view, because
|
||||||
|
// a written-down default is right only while `GetDefaultPage()`
|
||||||
|
// agrees with it. That is only safe if the empty value matches
|
||||||
|
// nothing: `isActive` compares strings, and a component asking
|
||||||
|
// about an id it does not have must not light up.
|
||||||
|
activeViewStore.setView('', true);
|
||||||
|
|
||||||
|
expect(activeViewStore.isActive('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('history store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
historyStore.setDepth(false, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds both answers, because forward is not back negated', () => {
|
||||||
|
historyStore.setDepth(true, false);
|
||||||
|
|
||||||
|
expect(historyStore.get()).toEqual({ canBack: true, canForward: false });
|
||||||
|
|
||||||
|
// The middle of the list: both directions available at once, which
|
||||||
|
// a single depth counter cannot express and which is the state the
|
||||||
|
// old `pushedEntries` got wrong.
|
||||||
|
historyStore.setDepth(true, true);
|
||||||
|
|
||||||
|
expect(historyStore.get()).toEqual({ canBack: true, canForward: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when neither answer changed', () => {
|
||||||
|
let notifications = 0;
|
||||||
|
const off = historyStore.subscribe(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
historyStore.setDepth(true, true);
|
||||||
|
historyStore.setDepth(true, true);
|
||||||
|
off();
|
||||||
|
|
||||||
|
expect(notifications).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with both unavailable, which is the truth at launch', () => {
|
||||||
|
// A fresh session is one entry deep and that entry is *replaced*,
|
||||||
|
// not pushed, so there is nothing of ours behind it. A control
|
||||||
|
// that assumed otherwise would offer a press that does nothing --
|
||||||
|
// and on Android, one the OS would have used to exit the app.
|
||||||
|
expect(historyStore.get()).toEqual({ canBack: false, canForward: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('track list store', () => {
|
describe('track list store', () => {
|
||||||
it('starts from the default column set', () => {
|
it('starts from the default column set', () => {
|
||||||
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
|
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user