Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e92721bb7 | ||
|
|
1ac919d228 | ||
|
|
5830b1ba17 | ||
|
|
2518385330 | ||
|
|
c8bc6db9fa | ||
|
|
559e1ed077 | ||
|
|
4ae6e13391 | ||
|
|
7d9e0bf2fb | ||
|
|
795f40acee | ||
|
|
5fb9a0d246 | ||
|
|
ca0f724e20 | ||
|
|
fbf1eff8f6 | ||
|
|
7acb197daf | ||
|
|
69ad558a44 | ||
|
|
9e0e4d5bb8 | ||
|
|
0cf710cf47 | ||
|
|
a37acfcf84 | ||
|
|
952c25c3d3 | ||
|
|
1d335c5180 | ||
|
|
df11ef23f4 | ||
|
|
55aa3ea5b0 | ||
|
|
fcf2fe509e | ||
|
|
da564f9659 |
@@ -18,7 +18,7 @@ here has disappeared.
|
||||
|
||||
## Read this part before you fail
|
||||
|
||||
Five things cost a cycle each the first time. They are here, not in a
|
||||
Seven things cost a cycle each the first time. They are here, not in a
|
||||
reference, because you need them *before* the failure, not after.
|
||||
|
||||
- **Time out every binding call.** A bound Go method called with wrong
|
||||
@@ -41,6 +41,25 @@ reference, because you need them *before* the failure, not after.
|
||||
the real `AddLibrary` binding and waits for the real scan.
|
||||
- **Playwright's WebKit does not run on Arch** (Ubuntu-only libs).
|
||||
`--browser=webkit` is CI-only; local work is Chromium.
|
||||
- **`make e2e` needs `SEED=default`.** Its specs assert on fixture
|
||||
content — unicode tracks, the fixture artists, a known playable file.
|
||||
Run against the `bulk` seed a measurement session left behind and 13
|
||||
of 36 fail, in a list that reads exactly like a regression in
|
||||
whatever you are holding. `make dev-headless SEED=default` first.
|
||||
- **A frontend edit is not live until you restart the app.** Vite
|
||||
updates the module, but an already-registered custom element class
|
||||
cannot be re-registered, so a running page keeps the old one and your
|
||||
change reads as having done nothing — including across a browser
|
||||
reload. `make dev-stop && make dev-headless SEED=…`, then re-check.
|
||||
The nastier version: a **build error leaves the dev server serving
|
||||
the last good bundle**, so the page still works and still shows the
|
||||
old behaviour. `make dev-headless` prints the esbuild error; a
|
||||
reload does not. One way to cause one is a stray backtick inside a
|
||||
comment in a `css` tagged template literal, which ends the literal.
|
||||
- **`npx tsc --noEmit` is part of the gate, and nothing else runs it.**
|
||||
CI does (`.gitea/workflows/ci.yml`), and it typechecks
|
||||
`frontend/test/` — which `make lint`, `make test`, `make ui-test` and
|
||||
`make e2e` do not. A tree can be green on all four and red in CI.
|
||||
|
||||
## Which tier
|
||||
|
||||
@@ -56,6 +75,7 @@ only climb when it cannot.
|
||||
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
|
||||
| A user-visible flow across frontend *and* backend | `make e2e` (needs the app up) | ~1 min |
|
||||
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
|
||||
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
|
||||
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
|
||||
|
||||
Two targets are once-per-clone prerequisites that are **not**
|
||||
@@ -73,13 +93,178 @@ Two rules about climbing:
|
||||
- **A component test passing is not the app rendering.** If you touched
|
||||
anything in `frontend/src`, verify it in the real app too — start it
|
||||
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
|
||||
Two of this repo's worst regressions were only ever visible there: a
|
||||
header badge contradicting the settings page, and a virtualized row
|
||||
whose columns no longer lined up with its own header. Neither failed
|
||||
anything.
|
||||
- **A list that renders is not a list that repaints.** `lit-virtualizer`
|
||||
re-renders its rows when one of its *own* properties changes, not
|
||||
when the parent does — so selection highlighting, the playing-track
|
||||
row and anything else driven by host state need an explicit
|
||||
`virtualizer.requestUpdate()`. Click a row and look, every time you
|
||||
touch one of these lists; the controller will hold the right state
|
||||
either way. **Check `el.viewActive` first**: dispatching a raw
|
||||
`navigate` event does not always activate a view, and an inactive one
|
||||
does not render at all — which looks exactly like this bug (the
|
||||
controller holds the selection, no row highlights) and is Phase 1
|
||||
working as designed. Navigate by clicking the sidebar.
|
||||
- **Do not write an e2e spec first.** Drive the flow by hand, then
|
||||
promote it with `/e2e`. Specs written blind assert on selectors that
|
||||
do not exist.
|
||||
|
||||
Before a commit, the gate is `make lint`, `make test`, `make ui-test`
|
||||
and `make bindings-check` — all four are also lefthook hooks, so
|
||||
skipping them locally only defers the failure.
|
||||
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
|
||||
`make bindings-check` and — from `frontend/` — `npx tsc --noEmit`. The
|
||||
first four are lefthook hooks, so skipping them locally only defers the
|
||||
failure; the typecheck is a hook too but only CI runs it over the test
|
||||
tree, which is where it has actually broken.
|
||||
|
||||
Two things about the e2e tier that are not obvious until they bite.
|
||||
**The 36 specs share one backend process in file order**, so a spec
|
||||
that leaves the app somewhere passes alone and fails the suite — leave
|
||||
the UI as you found it, and *wait* for it rather than trusting the
|
||||
click to have finished. The queue panel's width is animated and the
|
||||
transport slides with it, so a click issued while it closes lands on
|
||||
whichever button moved under the pointer. And **anything asserting on
|
||||
the queue panel's rows must open it first**: a closed panel renders no
|
||||
list at all.
|
||||
|
||||
## Measuring, when a pass is not the answer
|
||||
|
||||
Performance claims need a before and an after on the same machine
|
||||
against the same library, or they are anecdotes. The fixture library
|
||||
is a few dozen tracks and cannot show any of it.
|
||||
|
||||
```bash
|
||||
make bulkdata # ~11 s, 466 MB into a gitignored .dev/
|
||||
make sandbox-seed-bulk # minutes: it is a real scan of 50 000 files
|
||||
make dev-headless SEED=bulk
|
||||
make perf LABEL=before # ... make the change ...
|
||||
make perf LABEL=after
|
||||
make perf-compare BEFORE=before AFTER=after
|
||||
```
|
||||
|
||||
Fourteen numbers: startup (and the count of cross-origin requests, which
|
||||
is whether the app works offline), the bundle's shape and each view's
|
||||
first open, keystroke-to-paint in the search box, what a naturally
|
||||
finished track provokes, what one favourite toggle costs, what sitting
|
||||
idle on Settings costs, what **scrolling** a long list costs (image
|
||||
bytes and the tier they were requested at, plus frame cost through the
|
||||
artist grid), what a long **Explore session** retains (heap sampled
|
||||
after each of twenty-four searches, plus every registered cache's
|
||||
size), what opening a **2 000-track playlist** costs (elements
|
||||
retained, eager cover requests, heap, and what one update pass costs
|
||||
and rebinds), what the **selection** costs (ordering the selected keys
|
||||
with one row selected at either end of 50 000 and with all of them, and
|
||||
what "Select all → Edit tags" blocks for), what an **update pass of the
|
||||
player bar** costs (querySelectors, layout reads, style writes and the
|
||||
read-after-write interleaves inside `updated()`, measured with a clean
|
||||
DOM and a dirty one, plus six seconds of real playback), how many
|
||||
**document pointer listeners** are installed at rest (via CDP, so
|
||||
nothing else in the run is perturbed), what **"play these"** costs for
|
||||
an artist, twenty albums and five genres, and heap after a scripted
|
||||
browse. It wraps every bound Go method, so "did that refetch the
|
||||
library" is a fact rather than an inference.
|
||||
|
||||
`window.__yjCacheStats()` reports every registered cache's entries,
|
||||
retained chars and cap in one eval — which is how you check a bound is
|
||||
still holding without rebuilding the reproduction that justified it.
|
||||
|
||||
Adding a number is usually the first half of an item's work: most
|
||||
findings are not among the seven, and the fix cannot be believed
|
||||
without one. Two rules for adding one.
|
||||
|
||||
**Stage what the seed does not have, idempotently and by name.** The
|
||||
bulk seed has one empty playlist, against which "toggling a heart
|
||||
refetches every playlist" costs nothing and cannot be reproduced; the
|
||||
favourite measurement builds ten 500-track playlists first. Staging by
|
||||
name means a before and an after see the same shape — and
|
||||
`dev-headless` restores the seed tarball on every launch, so it is
|
||||
rebuilt each run anyway.
|
||||
|
||||
**Measure both halves of a trade.** Route splitting reports bytes
|
||||
before first paint *and* the slowest first open of a view, because a
|
||||
split that halves startup by making every page visibly slower has not
|
||||
helped anyone.
|
||||
|
||||
**Measure the state the cost depends on, not just the operation.** A
|
||||
forced layout costs 3 µs against a clean layout and 0.1 ms against a
|
||||
dirty one, so a component measured only in its steady state reports
|
||||
that the finding about it is imaginary. If the work is conditional,
|
||||
stage both conditions and put both rows in the table — they explain
|
||||
each other, and one of them is the number the fix has to move.
|
||||
|
||||
Fourteen traps, each of which produced a wrong number first:
|
||||
|
||||
- **A label is a filename, and audit IDs are case-insensitive as
|
||||
filenames.** `.dev/perf/before-m6.json` is the *capital* `M6` (the
|
||||
3 s ticker) from an earlier pass; measuring lowercase `m6` under that
|
||||
name silently overwrites a baseline three passes of numbers depend
|
||||
on. Name a label after the *change*, not the finding.
|
||||
- **The first run after a rebuild is not a measurement — and the
|
||||
second is not reliably a good one either.** A run taken immediately
|
||||
after `make dev-headless` often reports first contentful paint at
|
||||
96–112 ms against 28–32 ms on the next run of the same build (a cold
|
||||
Vite module graph). But the ordering does not hold: one pass saw 100
|
||||
then 96, and another 28 then 76. FCP moves ±50 ms for reasons this
|
||||
harness does not control, so take two, and if they disagree report it
|
||||
as noise rather than taking a third until they agree.
|
||||
- **A measurement is against whatever seed the app is running.**
|
||||
`make e2e` needs `SEED=default`, so a confirming perf run taken
|
||||
straight after one measures a few dozen tracks: "Play 20 albums"
|
||||
becomes a dash and an artist's bytes fall 40×. Plausible in shape,
|
||||
meaningless. Restart on `bulk` before re-measuring anything.
|
||||
- **A `longtask` entry is delivered *after* the task that produced
|
||||
it.** Reading `window.__yjPerf.longtasks` synchronously after the
|
||||
operation you just timed reports **0 ms of blocking beside a
|
||||
six-second stall**. Wait a couple of hundred milliseconds first. The
|
||||
tell is that the two numbers in the row disagree — which is a good
|
||||
reason to always measure blocking *and* wall time.
|
||||
|
||||
|
||||
- **`make dev-headless` immediately after `make sandbox-seed`** loses
|
||||
the race for port 34115 and comes up with no dev server, while still
|
||||
printing `up`. The measurement then attaches to a dying app. Sleep,
|
||||
or check `curl -s -o /dev/null -w '%{http_code}' localhost:34115`.
|
||||
- **`search-bar` debounces 150 ms.** Anything measuring to the next
|
||||
frame measures the input echoing its own character.
|
||||
- **`__yjEvents.wait()` returns an already-buffered event.** Without a
|
||||
`reset()` first you get the previous run's answer, which looks like a
|
||||
real result and is off by one iteration.
|
||||
- **A `0 ms` result is usually a broken measurement, not a win.**
|
||||
Waiting for `#main-content > :not(.view-hidden)` after a navigation
|
||||
matches the view being left — it stays on screen until the incoming
|
||||
one is ready — so every view reported 0 ms on every build. Wait for
|
||||
the specific element, never a generic selector. Same tell as the
|
||||
debounce: **a number that cannot move is not evidence.**
|
||||
- **`git stash` will not give you a baseline** on a tree carrying
|
||||
uncommitted phases: stashing one file reverts *every* uncommitted
|
||||
change in it, not the one being measured. Build the before by undoing
|
||||
the single change by hand in the current file. For a cap or a
|
||||
threshold, setting the constant to `Infinity` is the cleanest
|
||||
possible one-variable undo.
|
||||
- **A bound cannot be verified by a run that never reaches it.** The
|
||||
first bounded build measured *identical* to the unbounded one,
|
||||
because the session cached 180 entries against a cap of 192 and never
|
||||
evicted anything. Same tell as the two traps above — before and after
|
||||
suspiciously equal. Make the session overrun the limit.
|
||||
- **A negative result inherits the coverage of whatever produced it.**
|
||||
Two sessions recorded the unbounded Explore caches as "does not
|
||||
reproduce" from a browse script that visits Explore and never
|
||||
*searches* in it — so both caches were empty the whole time. Before
|
||||
believing a finding did not reproduce, check the code path it names
|
||||
actually ran.
|
||||
- **A measurement that warms something has to run after everything
|
||||
that reads it.** The playlist-open number pulls ~90 cover images;
|
||||
placed before the scroll measurement it filled the HTTP cache and
|
||||
took that row's request count from 26 to 0 — a clean, plausible,
|
||||
entirely fabricated improvement in a number nothing had touched. It
|
||||
runs last now, which costs it its own request count (zero on any
|
||||
build, so that row is in the JSON and off the table).
|
||||
- **The bulk library's covers are 300×300 and ~3.7 kB**, deliberately
|
||||
(a realistic cover generator made a 2 GB library). Any finding about
|
||||
full-size artwork cannot show its magnitude here; measure the
|
||||
mechanism instead — e.g. *which tier the request asked for* rather
|
||||
than bytes saved.
|
||||
|
||||
## Running the app
|
||||
|
||||
|
||||
@@ -665,3 +665,597 @@ of the working tree, and never two jobs in one directory. The
|
||||
distinction that matters is not clean-vs-dirty but *whose* dirt: a
|
||||
working-tree copy carries a developer's accumulated build output, which
|
||||
is the one thing CI is supposed to be checking you do not depend on.
|
||||
|
||||
## A cached view needs a lifecycle, and so do its controllers
|
||||
|
||||
Plan 007 phase 1. `index.ts` caches primary views and hides them with a
|
||||
class so `scrollTop` survives navigation — a deliberate, good decision
|
||||
that nothing else was told about. `disconnectedCallback` therefore
|
||||
never fires, and every document listener, interval and subscription a
|
||||
view registers runs for the session. The measured cost was not a leak:
|
||||
pressing `s` on **Settings** skipped albums out of the Autotag queue,
|
||||
because `autotag-view`'s document keydown handler was still live.
|
||||
|
||||
Three things that were not obvious before doing it:
|
||||
|
||||
- **A focus-only scope rule would have been a regression.** The
|
||||
shortcut service resolves a panel scope by walking up from the
|
||||
focused element, and this app is driven with the mouse: focus sits on
|
||||
`<body>` almost always. Panel bindings would only have worked after
|
||||
a click landed inside the panel, where the old document listener
|
||||
worked always. Hence the *ambient* scope claimed by the active view
|
||||
(`services/shortcut-scope.ts`) as a fallback after the focus walk.
|
||||
- **Shared reactive controllers have the same bug.**
|
||||
`ContextMenuController` bound three document listeners in
|
||||
`hostConnected`, which for a cached host never un-happens. A
|
||||
controller cannot know whether its host is cached, so
|
||||
`registerViewAware` lets it ask, and it keeps connection-based
|
||||
behaviour on hosts that are not.
|
||||
- **Off-screen views were still rendering.** Store controllers call
|
||||
`requestUpdate()` on every subscriber, so one keystroke in the search
|
||||
box re-rendered eleven pages, ten of them invisible. The mixin
|
||||
withholds the update and replays it on activation, which is why
|
||||
coming back to a view still shows current state.
|
||||
|
||||
Re-running a view's *load* on activation is not free and is not always
|
||||
right: `autotag-view`'s `startQueue()` resets the selected folder and
|
||||
refetches candidates over the network, so it stays once-per-mount and
|
||||
only the local folder list refreshes on return.
|
||||
|
||||
## A local timer is not a clock, and a fixed grid row is not a notice board
|
||||
|
||||
Plan 007 phase 2. Both halves of the finding were reproduced by hand
|
||||
first, and both reproduced exactly as measured in August: the seek bar
|
||||
read `00:44` against a backend at `73` after four keyboard seeks, and
|
||||
a queue with a moved file in the middle stopped dead at index 0 with
|
||||
nothing emitted and `IsPlaying` false.
|
||||
|
||||
Four things worth keeping:
|
||||
|
||||
- **Phase 1 moved the reproduction.** With a track row focused the
|
||||
arrows belong to the grid, so the keyboard seek does not fire from
|
||||
the track list at all any more — the 30 s desync only reproduces
|
||||
with focus off the grid. A fix verified against a stale
|
||||
reproduction would have "passed" without ever running the code
|
||||
path. Re-run the reproduction on the current build, not on the
|
||||
audit's description of it.
|
||||
- **A push of state needs an identity and a sequence.** The store is
|
||||
a singleton and keeps the last position, so a seek bar mounting
|
||||
later adopts it: without `trackChangeId` on the payload that is a
|
||||
stale reading rendered as current. And without a monotonic `seq`,
|
||||
a report of the same second as the last one is indistinguishable
|
||||
from no report, so the interpolation it is supposed to reset keeps
|
||||
running. Both are cheap on the emit side and impossible to add
|
||||
later without touching every consumer.
|
||||
- **`.bottom-bar` is a fixed `4em` grid row.** An inline message laid
|
||||
out inside it squeezes the transport out of its own footer, which
|
||||
looks like a broken player rather than a message. It floats above
|
||||
the bar (`position: absolute; bottom: calc(100% + 4px)`), which is
|
||||
also the right answer for anything else that wants to speak from
|
||||
down there.
|
||||
- **Reporting by event beat returning an error.** The plan wanted the
|
||||
queue bindings to return `error`; the failure that mattered most —
|
||||
auto-advance onto a bad file — has no caller to return to. An event
|
||||
covers both, and the stores kept a `.catch()` per call for the
|
||||
bridge-level rejections that a return value never described anyway.
|
||||
|
||||
## A level says how loud, not where, and the bottom band is taken
|
||||
|
||||
Plan 007 phase 3. The audit's ~30 "the failure is invisible" findings
|
||||
were one problem wearing thirty hats — there was nowhere to put a
|
||||
message — so the surface shipped whole: four levels, one store, one
|
||||
presentation, and the callers routed through it in the same pass.
|
||||
|
||||
Five things worth keeping:
|
||||
|
||||
- **A level is not a location.** Blocking, Persistent and Transient say
|
||||
*how loud*; Inline says *not global*, which is not the same as
|
||||
saying where. An inline notification therefore carries a **region**
|
||||
(`player`, and whatever comes next) and the app-level host ignores
|
||||
it. Without that field the "one component with four presentations"
|
||||
would have become two components with two stores, which is the exact
|
||||
thing this phase existed to delete.
|
||||
- **The bottom of the window belongs to the player.** The stack was
|
||||
first anchored above the player bar, beside the player's own floating
|
||||
notice. That looked right at 1440×900 and overlapped at 800×600,
|
||||
because the player's notice grows *upward* by however many lines its
|
||||
sentence needs. The stack moved under the header. Anything anchored
|
||||
to the bottom edge is sharing a band with something whose height is
|
||||
not known in advance.
|
||||
- **Some backend errors are already sentences.** `describeError` maps
|
||||
runtime causes to copy, but the sentinels this app writes for its own
|
||||
conditions ("a library with that name already exists") are the most
|
||||
useful thing that could be shown, and mapping them to a generic line
|
||||
would have been a regression. `explainError` repeats a message with
|
||||
no Go/HTTP noise markers and defers to the map otherwise. The
|
||||
distinction is whether *we* wrote the string, not how long it is.
|
||||
- **C4 and M1 are the same bug from either end.** The library store
|
||||
cached a stale answer because a fetch outlived the selection, and
|
||||
hung its waiters forever because a failed fetch never satisfied the
|
||||
"loaded and not loading" predicate they watched for. Both go away by
|
||||
holding the request itself and stamping it with a cache generation —
|
||||
one change, two findings, and the four hand-written `waitFor*`
|
||||
helpers deleted.
|
||||
- **A reproduction can fail for the wrong reason.** The e2e spec for a
|
||||
rejected binding renamed the decoy library to its own name, which the
|
||||
backend accepts as a no-op: red at the right assertion, having never
|
||||
induced the failure it was named for. It only became a reproduction
|
||||
once it picked its row by the seeded library's name. A failing test
|
||||
is evidence of nothing until you have watched *why* it fails.
|
||||
|
||||
One operational note: `make bindings-check` requires a clean working
|
||||
tree for `frontend/wailsjs/` and reports staged changes as dirty, so it
|
||||
cannot pass mid-phase on an uncommitted tree. Regenerating and diffing
|
||||
by hand (`go tool wails generate module -tags webkit2_41`, then
|
||||
`git diff -- frontend/wailsjs`) is the equivalent check.
|
||||
|
||||
## Measuring is the work; the icon CDN was serving Pro
|
||||
|
||||
Plan 007 phase 4, items 1–2 of 8. This phase is verified by numbers
|
||||
rather than by assertions, which changes what "first" means: the first
|
||||
deliverable is not a fix, it is a 50 000-track library and a script
|
||||
that takes four measurements against it. Both fixes then landed with a
|
||||
before/after, and both had a reproduction that was watched failing.
|
||||
|
||||
Six things worth keeping:
|
||||
|
||||
- **A measurement library is not a fixture library, and should share
|
||||
nothing but its generator.** `test_data/music_library_test` is
|
||||
curated *cases* selected by name; `.dev/music_library_bulk` is a pile
|
||||
whose only interesting property is its size. Generating 50 000 files
|
||||
through ffmpeg is ~40 minutes, so the bulk one encodes six clips once
|
||||
and copies them — but it still tags every file through
|
||||
`backend/tagwriter`, because a library the app cannot read back
|
||||
measures nothing. 11 s, 466 MB, gitignored, and deliberately not a
|
||||
dependency of `make test`.
|
||||
- **The first cover renderer made a 2 GB library.** The fixture cover's
|
||||
diagonal band is ~37 hard edges at 300 px, which is the worst case
|
||||
for a JPEG DCT: ~35 kB per album, nearly all of it artefacts around a
|
||||
pattern nobody looks at. A smooth gradient is ~6 kB and just as
|
||||
distinguishable. 466 MB instead of 2 GB.
|
||||
- **Instrument the bindings, not the symptoms.** "Finishing a track
|
||||
refetches the library" became a fact rather than an inference by
|
||||
wrapping every method on `window.go` and recording call, duration and
|
||||
serialized size. The generated bindings look their target up at call
|
||||
time (`window['go']['library']['Library']['GetAllTracks']()`), so
|
||||
post-hoc wrapping catches a store that imported the wrapper long ago.
|
||||
Pair it with a `longtask` PerformanceObserver: a 25 MB JSON parse on
|
||||
the main thread appears there and nowhere else.
|
||||
- **A debounce will happily measure nothing.** "Keystroke to paint"
|
||||
against the next frame gave 16 ms on every build, because
|
||||
`search-bar` debounces 150 ms and 16 ms is the input echoing its own
|
||||
character — a number that cannot move, and therefore cannot be
|
||||
evidence. The measurement has to wait past the debounce for the
|
||||
render the keystroke caused.
|
||||
- **The icon CDN was serving Font Awesome _Pro_.** Every SVG fetched
|
||||
from the kit host carries a "Commercial License" comment, so the
|
||||
obvious fix — save what the app already downloads — is a licence
|
||||
violation. Font Awesome **Free** 7.3.1 (CC BY 4.0) has all 64 names
|
||||
the app uses, is redistributable with attribution, and moved no
|
||||
`ui-visual` baseline. Check what a CDN is actually serving before
|
||||
vendoring it.
|
||||
- **Some icon names cannot be found statically.** Twenty call sites
|
||||
compute one from state (`jobIcon(job)`, `TONE_ICONS[tone]`,
|
||||
`this.favCtrl.iconName`), so the list is committed and *checked at
|
||||
runtime*: the resolver records a miss and renders a fallback, and an
|
||||
e2e sweep across every view asserts there are none. A missing icon
|
||||
used to be invisible because the CDN had everything; it now has to be
|
||||
findable instead.
|
||||
|
||||
And two findings that did not survive contact:
|
||||
|
||||
- **`perf.M1`/`M2` no longer reproduce.** One keystroke costs 49.9 ms
|
||||
net of the debounce with **zero** long-task blocking at 50 000
|
||||
tracks, not the predicted 50–100 ms across every mounted view —
|
||||
because Phase 1 stopped off-screen views rendering, which was M1's
|
||||
mechanism. An audit finding can be fixed by an unrelated phase, and
|
||||
re-measuring before fixing is how you find out.
|
||||
- **`perf.M7`/`M8`'s unbounded caches did not show as heap growth**
|
||||
across a ten-view scripted browse (37 → 38 MB post-GC). They are
|
||||
real by inspection, but the reproduction has to be a long Explore
|
||||
session, and it should exist before the LRU does.
|
||||
|
||||
One correction to the audit's own numbering, since two phases cite it:
|
||||
in `perf.md` the icons are **M9** (the plan's Phase 4 prose calls them
|
||||
C1), the whole-library refetch is **C1**, the selection wipe is **C2**,
|
||||
and **C3/C4 were already fixed in Phase 3**.
|
||||
|
||||
## A ticker is a hidden dependency for everything that forgot to speak
|
||||
|
||||
Plan 007 phase 4, items 3–5 (`C5`, `M6`/`H-14`, `M10`). Three fixes,
|
||||
three new measurements, and one bug shipped-and-caught inside the same
|
||||
session — the useful part of which is *how* it was caught.
|
||||
|
||||
Five things worth keeping:
|
||||
|
||||
- **Deleting a polling loop is never only a deletion.** The explore
|
||||
index emitted its status every 3 s forever, with an identical payload
|
||||
once ready, which re-rendered the whole settings page for the life of
|
||||
the session. Every path that *mutates* the status already emitted, so
|
||||
the ticker looked purely redundant. It was not: `si.ready = true` and
|
||||
`si.cancel = nil` both change what `emitStatus` derives and neither
|
||||
announced itself, so the ticker was carrying two transitions within
|
||||
three seconds of their happening. Removing it left the header badge
|
||||
reading "Building search index" over an index the settings page
|
||||
called ready. Before removing a poll, enumerate the writes to
|
||||
everything it reports — `rg 'si\.ready = |\.cancel = '` was the whole
|
||||
audit, and it should have come first rather than second.
|
||||
- **The screenshot found it; no test did.** The Go tests passed, the
|
||||
436 component tests passed, all 36 e2e specs passed, and the numbers
|
||||
were exactly the improvement predicted. The contradiction was two
|
||||
labels 700 px apart in a PNG. "Read the PNG" earns its place in the
|
||||
gate on cases like this — the app was *self-inconsistent*, which no
|
||||
assertion was looking for because nobody had thought to.
|
||||
- **A "0 ms" measurement is usually a broken measurement.** View-open
|
||||
time waited for `#main-content > :not(.view-hidden)`, which matches
|
||||
the view being navigated *away from* — it is still on screen until
|
||||
the incoming chunk resolves. Every view, every build, 0 ms. This is
|
||||
the same failure as the 150 ms debounce from the first pass, and the
|
||||
same tell: a number that cannot move is not evidence. Both times the
|
||||
fix was to wait for the specific thing, not for a generic selector.
|
||||
- **A before/after must differ in exactly one thing, and `git stash` is
|
||||
not a way to arrange that** on a tree carrying four uncommitted
|
||||
phases. Stashing `frontend/index.ts` to measure the pre-split bundle
|
||||
also reverted the bundled-icon registration living in the same file:
|
||||
22 cross-origin requests, and a baseline for a build that has never
|
||||
existed. The honest baseline was made by *adding the static imports
|
||||
back* to the current file — a change that undoes the one thing being
|
||||
measured and nothing else.
|
||||
- **The cheapest half of a fix is often the one the audit did not
|
||||
name.** `perf.C5` is written as an event handler that over-fetches,
|
||||
and it is. But `playlistStore` is a singleton constructed at import
|
||||
time and eagerly warmed itself as well, so every launch paid the same
|
||||
2.6 MB whether or not Playlists was ever opened — the event costs
|
||||
that on a user action, the constructor costs it on every start. "Only
|
||||
when there is a subscriber" turned out to be a two-line change that
|
||||
beat the patching logic it was written to support.
|
||||
|
||||
And one thing about splitting a bundle: **report the trade, not the
|
||||
win.** Route splitting moved 666 kB out of the pre-paint path (1 480 →
|
||||
814 kB) and cost up to 6 ms on the *first* open of a view, once per
|
||||
session, hidden further by warming the chunks on idle. But first
|
||||
contentful paint did not move at all, because at localhost speeds over
|
||||
a warm cache 666 kB of JS is not what the paint was waiting for. The
|
||||
number that improved is real and is the one that costs on a cold start
|
||||
and under WebKit2GTK; the number a user watches did not change. Saying
|
||||
both is the difference between a measurement and an advertisement.
|
||||
|
||||
## "We looked and saw nothing" is only evidence if the thing that fills it ran
|
||||
|
||||
Plan 007 phase 4, items 6 and 7 (`M7`/`M8`, `M3`/`M4`). Two fixes, two
|
||||
new measurements, and one finding that two previous sessions had come
|
||||
within a sentence of deleting as unreproducible.
|
||||
|
||||
`perf.M7` says the Explore art caches are never evicted. Two sessions
|
||||
measured a ten-view scripted browse, saw the heap go 37 → 38 MB
|
||||
post-GC, and recorded the finding as "real by inspection but it does
|
||||
not show up". Both were right about the number and wrong about what it
|
||||
meant: **the browse script navigates to Explore and never types in it,
|
||||
and both caches are filled only by a search.** It was measuring a view
|
||||
with two empty maps. A session of twenty-four searches grows the heap
|
||||
20.58 MB and is still accelerating at the end.
|
||||
|
||||
Six things worth keeping:
|
||||
|
||||
- **A negative result inherits the coverage of the thing that produced
|
||||
it.** "We browsed ten views and the heap was flat" sounds like
|
||||
evidence about caches; it is evidence about ten navigations. Before
|
||||
believing a finding did not reproduce, check that the code path it
|
||||
names actually executed — here, one `console.log` of
|
||||
`thumbnailCache.size` would have ended the question two sessions
|
||||
earlier. Phase 4 has now had three findings evaporate on contact
|
||||
(`M1`, `M2`, and half of `M8`) which makes the fourth *look* like the
|
||||
same thing, and that prior is exactly what made it cheap to accept.
|
||||
- **A bound cannot be verified by a run that never reaches it.** The
|
||||
first bounded build measured identical to the unbounded one: twelve
|
||||
searches cached 180 thumbnails against a cap of 192, so nothing was
|
||||
ever evicted. This is the same trap as the 150 ms debounce and the
|
||||
`:not(.view-hidden)` selector, in its third costume — *a number that
|
||||
cannot move is not evidence* — and the tell is the same one every
|
||||
time: before and after are suspiciously equal.
|
||||
- **Two caches holding the same string means bounding one frees
|
||||
nothing.** `explore-view`'s `artistImageCache` and
|
||||
`exploreCache.artists` both hold the artist photo's base64 data URL,
|
||||
~128 kB each, measured at 2.30 M chars in *both* maps. Capping either
|
||||
alone leaves every string pinned by the other, and the measurement
|
||||
would have read as a fix that did not work. The cap is a shared
|
||||
exported constant now. Before bounding a cache, find every reference
|
||||
to what it holds.
|
||||
- **The audit named the wrong two maps.** `M8` calls out `artistAlbums`
|
||||
and `artistTopTracks` as holding discographies and top-track lists.
|
||||
Nothing in the app has ever written to either — their only callers
|
||||
were a component test. Deleted rather than bounded. The map that
|
||||
actually retains is one the audit does not mention.
|
||||
- **A measurement library optimised for size can remove the property a
|
||||
finding is about.** `M3` is "the Art column renders a 1500×1500
|
||||
original into a 24 px box". The bulk library's covers are 300×300 and
|
||||
3.7 kB, because generating 50 000 realistic covers made a 2 GB
|
||||
library and a smooth gradient made a 466 MB one. So the bytes saved
|
||||
here are 3.7 kB → 1.1 kB and prove nothing. The number that is not
|
||||
hostage to the fixture is **which tier was requested** — 26 of 26
|
||||
originals before, 0 after, true on any library. When the rig cannot
|
||||
show the magnitude, measure the mechanism.
|
||||
- **An audit's arithmetic is a hypothesis too.** `M4` predicts 250 000
|
||||
comparisons per scroll frame from 5 000 albums × ~50 visible cards.
|
||||
Measured: 24 visible cards, and the scan breaks on its first match,
|
||||
so it costs **1.46 ms per frame** — real, 146× improvable, and far
|
||||
below the long-task threshold, so it moves no user-visible number
|
||||
today. Worth fixing because it stops scaling with the library, not
|
||||
because anything was stuttering. Say which of those two it is.
|
||||
|
||||
One operational trap that cost a cycle and is now in the skill:
|
||||
**`make e2e` needs `SEED=default`.** Run against the bulk seed left
|
||||
over from a measurement session, 13 of 36 specs fail on fixture
|
||||
content — unicode tracks, fixture artists, the seeded playback file —
|
||||
and the failure list reads exactly like a regression in the change you
|
||||
are holding.
|
||||
|
||||
## A virtualizer repaints on its own properties, and the sloppy thing doing that may be load-bearing
|
||||
|
||||
Plan 007 phase 4, item 8 (`M5`) and part of the tail (`p3`, `m1`, `m7`,
|
||||
`p4`). One large fix, three tail items settled, one audit
|
||||
recommendation rejected as a bug, and a broken feature that no audit
|
||||
had noticed.
|
||||
|
||||
The mechanism under most of it is one sentence: **`<lit-virtualizer>`'s
|
||||
rows are rendered by the `virtualize` directive, and that directive
|
||||
runs when one of the *virtualizer's own* properties changes — not when
|
||||
its parent re-renders.** Everything below follows from that.
|
||||
|
||||
Seven things worth keeping:
|
||||
|
||||
- **Memoising `items` and hoisting `renderItem` together is how you
|
||||
build a list that never repaints.** Virtualizing the playlist views
|
||||
needed both (that is the point), and selection went silently dead:
|
||||
the controller held exactly the right keys and no row ever showed
|
||||
one. Nothing failed — 447 component tests, 36 e2e specs and every
|
||||
Go test stayed green. A click in the real app found it in ten
|
||||
seconds. The fix is what `track-list` has always done and nobody had
|
||||
written down: push `virtualizer.requestUpdate()` on a selection
|
||||
change and on a playing-track change.
|
||||
- **The same fact makes `perf.m1` a regression.** It asks for
|
||||
`artists-view` and `genres-view` to hoist their per-render arrow
|
||||
functions to stable fields "as `cover-grid` already does". That fresh
|
||||
closure is the only thing changing a virtualizer property on a host
|
||||
update, i.e. the only thing repainting the cards. Measured in the
|
||||
running app: 1 highlighted card before the change, 0 after, both
|
||||
views. There is no compensating win — the host mostly re-renders
|
||||
*because* card state changed — so the closures stay, and
|
||||
`card-grid-repaint.test.ts` fails on the change and exists for no
|
||||
other reason. **An audit's suggested fix is a hypothesis too**, and
|
||||
this is the first one in this phase that was actively harmful rather
|
||||
than merely wrong about magnitude.
|
||||
- **Two of `M5`'s four stated mechanisms did not survive
|
||||
measurement.** "lit removes and re-adds 10 000 listeners per pass" is
|
||||
false on any build: instrumenting `EventTarget.prototype` recorded
|
||||
**zero** add/remove calls per pass, because lit-html's `EventPart` is
|
||||
itself the listener (`handleEvent`) and a changed listener value
|
||||
updates a field rather than the DOM. And one update pass cost 5.3 ms,
|
||||
not a stall. What was real, and worse than predicted, was elements
|
||||
retained: **22 090** for a 2 000-track playlist against the audit's
|
||||
16 000, and 2 000 eager cover requests. Fixing the two real halves
|
||||
gives 487 elements and 0.
|
||||
- **The suggested fix would have cost two features.** "Render these
|
||||
through `<track-list>` the way `genre-details` does" holds for
|
||||
`genre-details` because a genre list is just tracks. Both playlist
|
||||
views render phantom rows for missing files, and `playlist-details`
|
||||
is a drag source and a drop target; `track-list` has never had
|
||||
either. Virtualizing in place got the same 45× on elements with none
|
||||
of the risk, and left `track-list` alone for its four other callers.
|
||||
Check what the reference implementation *does not* do before adopting
|
||||
it.
|
||||
- **A row inside a virtualizer needs `width: 100%`.** The virtualizer
|
||||
positions children absolutely, so a grid row shrinks to fit its
|
||||
content: the columns silently stopped lining up with the header above
|
||||
them. Caught by reading the screenshot, not by any assertion — the
|
||||
second time in this phase that a PNG found what the suite could not.
|
||||
- **A write with a `RETURNING` clause is still a write.**
|
||||
`CreateSmartPlaylist` issued its `INSERT ... RETURNING` through
|
||||
`DB.QueryContext`, which routes to the query-only read pool, and
|
||||
failed with "attempt to write a readonly database (8)" — so **no
|
||||
smart playlist could be created at all**, in any real build. It was
|
||||
invisible because `NewTestDB` shares one in-memory connection and
|
||||
leaves `readDB` nil, so `reader()` hands back the *writer* under test:
|
||||
every unit test of that path exercised a handle production does not
|
||||
have. `TestNoWritesOnTheReadPool` now walks the tree for the class,
|
||||
watched failing on the bug first. A test double that collapses two
|
||||
handles into one cannot see a bug about which handle you used.
|
||||
- **`p3` is right about one store and wrong about the other.**
|
||||
Coalescing `search-store`'s notify to a microtask makes a subscriber
|
||||
that unsubscribes synchronously after a `setTerm` miss the
|
||||
notification entirely — a semantic change, and one an existing test
|
||||
had already pinned deliberately. `playlist-store` took the fix; the
|
||||
keystroke store did not. "Make these five consistent" is a fine
|
||||
instinct and a bad rule when one of them is on a different path.
|
||||
|
||||
And two operational notes, both now in the skill:
|
||||
|
||||
- **A frontend edit is not live until the app restarts.** Vite HMR
|
||||
updates the module, but an already-registered custom element class
|
||||
cannot be re-registered, so the running page keeps the old one — the
|
||||
edit reads as having done nothing. Worse, a *build error* leaves the
|
||||
dev server serving the last good bundle, silently: a stray backtick
|
||||
inside a comment in a `css` tagged template literal ended the literal,
|
||||
esbuild failed, and the page kept rendering the previous CSS while
|
||||
`make dev-headless` printed nothing about it.
|
||||
- **`tsc --noEmit` is in CI and was not in the documented gate.** The
|
||||
previous pass left the tree failing it, under a fully green
|
||||
`make lint && make test && make ui-test && make e2e` — none of which
|
||||
typechecks `frontend/test/`.
|
||||
|
||||
## An audit's magnitude and its mechanism are two claims, and the fix is a third
|
||||
|
||||
Plan 007 phase 4, fifth pass: the `track-details` chunk split and `m6`.
|
||||
Two items, both landed, and the pass's one useful generalisation is
|
||||
that a finding is really *three* hypotheses — how big it is, why it is
|
||||
that big, and what to do about it — which can be independently right
|
||||
and wrong.
|
||||
|
||||
`perf.m6` got the first right, the second wrong, and the third half
|
||||
wrong:
|
||||
|
||||
- **Right about size.** "Select all → Edit tags at 50 000 tracks will
|
||||
hang the renderer." Measured through the real opener: **3.0–6.3 s**
|
||||
of blocked main thread, varying that much run to run on one build.
|
||||
It is the largest single stall this phase has found, and it was in
|
||||
the *minor* tier of the audit.
|
||||
- **Wrong about why.** The audit calls it O(selection × total) —
|
||||
2.5 × 10⁹ comparisons. It is not: select-all hands the opener its
|
||||
keys *in list order*, so each `find` matches at index *i* and the
|
||||
real cost is N²/2, quadratic in the **selection**. That matters for
|
||||
what it predicts about everything else: the audit's formula says a
|
||||
ten-track selection costs 500 000 comparisons (it costs about 50),
|
||||
and says nothing about the genuine worst case, which is a selection
|
||||
built from the *bottom* of the list.
|
||||
- **Half wrong about the fix.** "Keep an index-ordered selection, and
|
||||
build a `Map<FilePath, Track>` for the batch lookup." The map is the
|
||||
entire 50× (**3 051–6 298 ms → 68 ms**), and it is now
|
||||
`utils/track-index.ts`, a `WeakMap` keyed on the array's identity —
|
||||
the invalidation signal this app already relies on everywhere else.
|
||||
The index-ordered selection is the unsafe half: an index goes stale
|
||||
on any re-sort, re-filter or refetch while a file path survives all
|
||||
three, which is exactly why `retain()` drops `lastSelectedIndex` and
|
||||
keeps the keys. The helper it would have replaced measures **3 ms**.
|
||||
Three milliseconds does not buy a silently mis-ordered queue insert.
|
||||
|
||||
That is the second audit recommendation in two passes that would have
|
||||
shipped a bug, after `m1`. Both times the reason was the same: the
|
||||
audit reasoned from the shape of the code and not from what the rest of
|
||||
the file already knew about it.
|
||||
|
||||
Five more things worth keeping:
|
||||
|
||||
- **A `longtask` entry arrives after the task that produced it.** The
|
||||
new measurement's first run reported `blocking: 0 ms` next to a
|
||||
six-second wall time, because it read the buffer synchronously after
|
||||
the await. Sixth variant of this phase's most-repeated trap, and the
|
||||
first one caught by *another number in the same row* contradicting
|
||||
it rather than by suspicion. Two numbers that must agree are worth
|
||||
more than one number you have to be sceptical about.
|
||||
- **The first load after a rebuild is not a measurement of first
|
||||
load.** FCP read 96–112 ms on every run taken immediately after
|
||||
`make dev-headless`, and 28–32 ms on the very next run of the same
|
||||
build. A cold Vite module graph, not variance. The plan had been
|
||||
describing this as "±100 ms run to run" for three passes without
|
||||
naming it.
|
||||
- **Measurement labels are a flat namespace; audit IDs are case
|
||||
sensitive.** `before-m6`/`after-m6` already existed — the *second*
|
||||
pass's capital `M6`, an unrelated finding about a 3 s ticker. Naming
|
||||
a baseline after a finding would have overwritten two of them.
|
||||
- **An unreachable code path still costs bundle size, and “dead code”
|
||||
can mean “missing feature”.** `cover-grid` is one of the five
|
||||
components that opened `track-details`, and it cannot: its album
|
||||
dropdown is rendered by `renderSplitGrid`, which `connectedCallback`
|
||||
references only to satisfy `noUnusedLocals` and which is, by its own
|
||||
comment, never invoked. Expanding an album fetches its ten tracks and
|
||||
draws nothing. The audit files this as `perf.p2`, "an unreferenced
|
||||
`renderSplitGrid`", under housekeeping. It is a whole interaction
|
||||
that does not exist, and it was only visible from trying to use it.
|
||||
- **What keeps a chunk out of a bundle is the absence of an import,
|
||||
which nothing notices.** Five static imports were what put
|
||||
`track-details`'s 42 kB before first paint; adding one back costs
|
||||
nothing anybody would see, because the chunk is also warmed on idle
|
||||
and the dialog carries on working. `lazy-track-details.test.ts` reads
|
||||
the five sources and fails on a returning import — the same shape as
|
||||
`TestNoDirectRuntimeEmits`, and for the same reason: the invariant is
|
||||
about what the code *does not* say.
|
||||
|
||||
## A finding's magnitude is measured where the work runs, not where it is written
|
||||
|
||||
Plan 007 phase 4, sixth pass: `m5`, `m4`, `m2` — the end of the tail,
|
||||
and the phase. Three items, one of which was measured and then
|
||||
*dropped*, which is the outcome the discipline exists to allow.
|
||||
|
||||
The generalisation the pass added to the previous one's "an audit's
|
||||
magnitude and its mechanism are two claims": **a mechanism can be
|
||||
exactly as described and still cost nothing, because the cost depends
|
||||
on state the reading cannot see.** `perf.m5` is right that
|
||||
`now-playing.updated()` interleaves layout reads with style writes on
|
||||
every pass, and right that the component updates while playing. It is
|
||||
wrong by two orders of magnitude, because a 1 Hz position report
|
||||
changes nothing that component renders — so the layout is clean when
|
||||
the reads happen and they cost 3 µs. The interleave only flushes when
|
||||
the DOM actually changed, measured at 0.103 ms, 34× more. The fix is
|
||||
still right (52 forced layouts over six seconds of playback became 2),
|
||||
but the number that justifies it had to be found by making the DOM
|
||||
dirty on purpose.
|
||||
|
||||
Seven things worth keeping:
|
||||
|
||||
- **A guard is only correct if it lists everything the measurement
|
||||
depends on, including things a CSS rule adds.**
|
||||
`.will-scroll .scroll-content` has `padding-right: 2em`, so applying
|
||||
the scroll class changes the distance the marquee has to travel:
|
||||
−128 px before the class, −158 px after it. The audit's "guard on the
|
||||
value/flag they already track" reads as "guard on the text", and a
|
||||
text-only guard would have left every first hover scrolling 30 px
|
||||
short — silently, with no test in any tier able to see it. That is
|
||||
the **third** audit recommendation in three passes that would have
|
||||
shipped a bug, after `m1` and `m6`, and all three failed the same
|
||||
way: reasoning from the shape of a function instead of from what the
|
||||
rest of the file already knows about it.
|
||||
- **Measuring is also how you decline to fix something.** The same
|
||||
finding names `artists-view` and `genres-view`, which do one
|
||||
`querySelector` and two `style.setProperty` per pass and **no layout
|
||||
read at all** — 0.0033 ms, one percent of their own update pass. They
|
||||
are the two files `perf.m1` was rejected in, where a guard risks
|
||||
stopping the virtualizer seeing a changed property. Three
|
||||
microseconds does not buy that risk, and "measured, declined" is a
|
||||
better record than a silent omission.
|
||||
- **A finding can be half-fixed by a phase that was not about it.**
|
||||
`m4` describes two components registering document `mousemove` in
|
||||
`connectedCallback` "for the process lifetime". Phase 1 had already
|
||||
moved `track-list`'s onto `listenWhileActive`, so half the finding
|
||||
described a build a year of work had passed. Check the line the audit
|
||||
cites still says what it said.
|
||||
- **An N+1 finding is usually also an N-bytes finding, and the audit's
|
||||
fix may only address the N.** All three `m2` sites want `FilePath`
|
||||
and ask for whole track rows to get it: five genres cost **6 MB over
|
||||
the IPC**, which the suggested `GetTracksByGenres([]string)` would
|
||||
have preserved exactly while removing four round trips. Returning
|
||||
paths made it 1.29 MB. Ask what the caller does with the answer
|
||||
before batching the question.
|
||||
- **Return grouped, not flattened, when the caller owns the order.**
|
||||
An album list is sorted by name and a genre selection by click order;
|
||||
a flattened result would have reordered a queue silently. The new
|
||||
bindings return `map[int64][]string` / `map[string][]string`, which
|
||||
also serves the drag cache — a fourth N+1 site the audit does not
|
||||
name, and the one that fires most, since it warms on every selection
|
||||
change rather than on a menu action.
|
||||
- **`make generate` was emitting TypeScript that does not parse.**
|
||||
`genevents` prefixed only the *first* line of a const block's doc
|
||||
comment with `//`; Phase 4's first pass gave `events.go` two
|
||||
multi-paragraph comments; so regenerating `frontend/src/events.ts`
|
||||
wrote bare prose into an object literal. It is a pre-commit hook, so
|
||||
the failure was waiting for whoever next touched a `.sql`, a `.templ`
|
||||
or an event constant. Nothing caught it because nobody had run the
|
||||
generator since the comments were written. **A generator is only
|
||||
verified by running it**, and a hook that regenerates is a hook that
|
||||
can break a clean tree.
|
||||
- **The `wailsjs` delta is 13 lines across *two* files**, both
|
||||
`autotagservice/Service.*`, not five as three sessions of notes have
|
||||
said. It is 25 across four now, the extra 12 being this pass's two
|
||||
library bindings.
|
||||
|
||||
And three on measuring, all of which produced a wrong number first:
|
||||
|
||||
- **"First run cold, second warm" is not a rule.** First contentful
|
||||
paint read 100 then 96 on one build this pass, and 28 then 76 on
|
||||
another — the second run warmer in neither. FCP varies ±50 ms here
|
||||
for reasons the harness does not control. The honest response is to
|
||||
report it as unattributable, not to take a third run until it agrees.
|
||||
- **A confirming run against the wrong seed looks like a result.** A
|
||||
re-run taken straight after `make e2e` measured the *default*
|
||||
library, because `make e2e` needs `SEED=default` and the app was
|
||||
still on it: "Play 20 albums" went from a number to a dash and the
|
||||
artist's bytes fell 40×. Plausible in shape, meaningless. The tell
|
||||
was a row that stopped having a value at all.
|
||||
- **Selection highlighting read from an inactive view measures Phase 1,
|
||||
not a repaint bug.** Driving `artists-view` after navigating with a
|
||||
raw `navigate` event showed the controller holding one selected
|
||||
artist and zero highlighted cards — the exact signature of the
|
||||
virtualizer hazard, and entirely an artifact: `viewActive` was
|
||||
`false` and an off-screen view does not render. Through a real
|
||||
sidebar click: one highlighted card, `aria-selected="true"` on the
|
||||
right one, in both card grids. Check `viewActive` before believing a
|
||||
view did not repaint.
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# Frontend accessibility & interaction-model audit — YellowJacket
|
||||
|
||||
Scope: `frontend/src/components/**`, `frontend/src/services/keyboard-shortcut-service.ts`,
|
||||
`frontend/index.html`, `frontend/index.ts`, `frontend/index.css`, `frontend/src/styles/tokens.css.ts`.
|
||||
Read-only; nothing was changed.
|
||||
|
||||
Already confirmed by hand and **not** re-reported: track rows / sidebar `<li>` not focusable,
|
||||
14 tab stops app-wide, closed queue panel still focusable, global Space/arrow/S/N/P hijack,
|
||||
`data-shortcut-scope` never set. Adjacent consequences of those are marked *(adjacent)*.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
**1. `frontend/src/components/config-page/config-section.ts:98-104` — the entire Settings page is unreachable by keyboard**
|
||||
The disclosure header is a bare `<div class="header" @click=${this.toggle}>` with no `<button>`,
|
||||
no `tabindex`, no `role`, no `aria-expanded`, no `aria-controls`. Sections default to
|
||||
`expanded = false` (line 84/88), so every setting in the app is behind a control that cannot be
|
||||
tabbed to or activated.
|
||||
*Symptom:* a keyboard or screen-reader user can open Settings and see nothing but collapsed
|
||||
headings they can never expand.
|
||||
*Fix:* make the header a `<button type="button" aria-expanded=${this.expanded} aria-controls="body">`
|
||||
and give the body an `id`.
|
||||
|
||||
**2. `frontend/src/components/downloads-view/downloads-view.ts:258-271` — tab switching is mouse-only and has no tab semantics**
|
||||
`<div class="tabs">` containing two `<div class="tab" @click>`; no `role="tablist"`/`role="tab"`,
|
||||
no `aria-selected`, no `tabindex`, no arrow-key handling, no `aria-controls` on the panel.
|
||||
*Symptom:* the Downloads tab of the Downloads view can never be reached without a mouse; AT
|
||||
announces two unlabelled generic containers.
|
||||
*Fix:* `role="tablist"` on the wrapper, `<button role="tab" aria-selected=... aria-controls=...>`
|
||||
per tab with roving tabindex.
|
||||
|
||||
**3. `frontend/src/components/track-list/track-list.ts:1967`, `frontend/src/components/queue-panel/queue-panel.ts:1543`, `frontend/src/components/cover-grid/cover-grid.ts` — context menus have no menu semantics, no focus, no keyboard**
|
||||
`<div class="context-menu-panel">` holds `wa-dropdown-item`s inside a raw `<wa-popup>`. The items do
|
||||
carry `role="menuitem"` (Web Awesome sets it — verified in
|
||||
`node_modules/@awesome.me/webawesome/dist/chunks/chunk.MCDD6PFW.js`), but the container has no
|
||||
`role="menu"`, so the menuitems are orphaned. Because they are in a bare `wa-popup` rather than a
|
||||
`wa-dropdown`, nothing moves focus into the menu, nothing handles Up/Down/Escape, and nothing
|
||||
restores focus on close. The menu only opens on `contextmenu` (mouse right-click); there is no
|
||||
Shift+F10 / Menu-key path.
|
||||
*Symptom:* Play, Add to Queue, Play Next, Add to Playlist, Favourite and Track Details are
|
||||
completely unavailable without a mouse — this is the only path to most of those actions.
|
||||
*Fix:* wrap in `role="menu"`, open on `keydown` Shift+F10/ContextMenu, focus the first item, handle
|
||||
Arrow/Escape/Tab, restore focus to the originating row on close.
|
||||
|
||||
**4. `frontend/src/components/autotag-view/autotag-view.ts:2824-2950` — four hand-rolled modal dialogs with no dialog semantics, no focus trap, no focus restore**
|
||||
`renderPasteDialog` (2824), `renderWarningDialog` (2856), `renderLeaveDialog` (2891),
|
||||
`renderSearchDialog` (2922) each render `<div class="dialog-overlay"><div class="dialog">` with no
|
||||
`role="dialog"`, no `aria-modal="true"`, no `aria-labelledby` pointing at the `<h3>`, and no focus
|
||||
management. Only the paste and search dialogs set `autofocus`; the Warning and Leave dialogs — the
|
||||
two that gate an **irreversible on-disk metadata rewrite** — leave focus wherever it was.
|
||||
*Symptom:* a screen-reader user is never told a dialog opened, can Tab straight out of it into the
|
||||
page behind, and can confirm "this rewrites audio files" without ever hearing the warning.
|
||||
*Fix:* use `<wa-dialog>` (which already does `showModal()` + activeElement restore — see
|
||||
`chunk.ZUIYLL2X.js`), or add role/aria-modal/labelledby + a Tab trap + focus save/restore.
|
||||
|
||||
**5. `frontend/src/components/autotag-view/autotag-view.ts:1706-1746` — bare single-letter shortcuts on `document`, including a destructive one, with an incomplete guard**
|
||||
`A` = Apply (rewrites tags on every track on disk, explicitly "not automatically reversible" per the
|
||||
warning copy at 2866-2872), `S` = Skip, `L` = Leave as-is, `U`/`F` = dialogs. The suppression check
|
||||
at 1707-1712 only tests `tagName === 'INPUT' | 'TEXTAREA' | isContentEditable`. Events originating
|
||||
inside a Web Awesome control's shadow DOM are retargeted to the host (`WA-SELECT`, `WA-INPUT`,
|
||||
`YJ-COMBOBOX`), so the guard passes and `A` fires while the user is typing. Buttons, checkboxes and
|
||||
`<select>` are likewise unguarded — pressing `S` on a focused `<select>` triggers Skip *and* jumps
|
||||
the option list.
|
||||
*Symptom:* typing an artist name into a Web Awesome field, or type-ahead on a select, silently
|
||||
rewrites metadata on an entire album.
|
||||
*Fix:* reuse `isTextInputFocused` from `keyboard-shortcut-service.ts` (which resolves through shadow
|
||||
roots via `getDeepActiveElement`) and require a confirm/modifier for `A`.
|
||||
|
||||
**6. `frontend/src/components/search-bar/search-bar.ts:166-174` and `frontend/src/components/explore-view/explore-view.ts:1317-1323` — clear buttons have no accessible name at all**
|
||||
Both are `<button class="clear-button">` containing only `<wa-icon name="xmark">`. No `aria-label`,
|
||||
no `title`, no text. (A systematic scan of every `<button>` in `components/**` found these two as the
|
||||
only truly unnamed controls; the rest have text or at least a `title` fallback.)
|
||||
*Symptom:* announced as "button" with no name; unusable via voice control.
|
||||
*Fix:* `aria-label="Clear search"`.
|
||||
|
||||
**7. `frontend/src/components/top-results-row/top-results-row.ts:267` — result cards are click-only divs**
|
||||
`<div class="card" @click=${() => this.handleClick(r)}>` — the only `role`/`tabindex`/`keydown`-free
|
||||
card renderer in the codebase (every other card view added at least `role="button" tabindex="0"`).
|
||||
*Symptom:* the top-results row on the Explore page cannot be activated by keyboard.
|
||||
*Fix:* `role="button" tabindex="0"` + Enter/Space handler, matching `home-view.ts:305-309`.
|
||||
|
||||
**8. `frontend/index.html:34` + `frontend/index.ts:263-275` — queue toggle has no state, and the closed panel is not inert** *(adjacent)*
|
||||
The button carries `aria-label="Toggle queue"` but never `aria-expanded` or `aria-controls`. The
|
||||
toggle just adds/removes the `open` attribute; the closed state is purely
|
||||
`:host { width: 0; overflow: hidden }` (`queue-panel.ts:214-217`), which hides nothing from the
|
||||
accessibility tree.
|
||||
*Symptom:* the button never reports open/closed, and a screen-reader's virtual cursor walks the
|
||||
entire queue (title, artist, remove button for every track) while the panel is visually closed.
|
||||
This is the same root cause as the already-confirmed "closed queue panel is still focusable".
|
||||
*Fix:* set `aria-expanded`/`aria-controls` on the button and `inert` (or `aria-hidden="true"` plus
|
||||
`visibility: hidden`) on the panel when closed.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
**9. `frontend/src/components/track-list/track-list.ts:1906-1926` — column headers are not headers and never expose sort state**
|
||||
`<div class="header-row">` with `<div class="header-cell" @click>` per column. No `role="grid"`/
|
||||
`row`/`columnheader`, no `aria-sort`, no `tabindex`, no keydown. The sort direction is conveyed only
|
||||
by a `▲`/`▼` glyph in a `<span class="sort-arrow">` at 10px (`track-list.ts:900-901`).
|
||||
*Symptom:* AT cannot tell which column the list is sorted by or in which direction, and clicking a
|
||||
header to sort is mouse-only. (There is a redundant keyboard-reachable sort dropdown at 1806-1841,
|
||||
so this is not a total loss of function.)
|
||||
*Fix:* `role="columnheader" aria-sort=${'ascending'|'descending'|'none'}` on each header cell and
|
||||
make it a `<button>`.
|
||||
|
||||
**10. `frontend/src/components/track-list/track-list.ts:1746-1755` — the per-row favourite toggle is an unlabelled, unfocusable div**
|
||||
`<div class=${classMap({'fav-icon': true, favorited: isFav})}>` with an inline `<svg>` and
|
||||
`cursor: pointer` (`track-list.ts:1034-1043`); the click is delegated off the virtualizer. No
|
||||
`role`, no `tabindex`, no accessible name, no `aria-pressed`.
|
||||
*Symptom:* favouriting a track from the list is mouse-only, and the current favourite state of every
|
||||
row is invisible to AT (heart/star fill is a shape-and-colour change with no text equivalent).
|
||||
*Fix:* `<button role="switch" aria-checked=${isFav} aria-label="Favourite ${track.TrackName}">`.
|
||||
|
||||
**11. `frontend/src/components/queue-panel/queue-panel.ts:1417` + `cover-grid.ts:1798`, `album-dropdown.ts:385`, `app-sidebar.ts:222-232` — drag-and-drop has no keyboard equivalent anywhere**
|
||||
Queue reordering (`draggable="true"` on `.track-item`, drop index computed from cursor Y at
|
||||
`queue-panel.ts:1093-1140`), album→queue/playlist drag, expanded-album track drag, and drop-on-nav-item
|
||||
are all pointer-only. There is no Alt+Up/Down reorder, no "move to…" command, and no `aria-grabbed`/
|
||||
`aria-dropeffect` substitute.
|
||||
*Symptom:* queue order can never be changed without a mouse. Combined with finding 3 (the context
|
||||
menu is mouse-only too), there is **no** keyboard path to add a track to the queue or a playlist.
|
||||
*Fix:* add Alt+ArrowUp/Down reorder on the focused queue item, and expose the drag targets as
|
||||
context-menu commands once the menu is keyboard-reachable.
|
||||
|
||||
**12. No `aria-live` region anywhere for async status — scan/job progress, toasts, search results, now-playing**
|
||||
A repo-wide grep finds exactly one live region: `catalog-scope-notice.ts:110` (`role="status"`), and
|
||||
even that is conditionally rendered *with* its content already present, which most ATs do not
|
||||
announce. Specific gaps:
|
||||
- `frontend/src/components/config-page/config-page.ts:2137-2139` — `<div class="toast">` with no
|
||||
`role="status"`/`aria-live`; it is the only feedback that a setting saved or failed, and it
|
||||
auto-dismisses after a timer (1174-1176).
|
||||
- `frontend/src/components/jobs/job-indicator.ts:359-370` — the trigger label swings between
|
||||
"Scanning Music", "3 background jobs" and "Finished" with no live region.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:340-357` — track title/artist change on every
|
||||
auto-advance with no announcement.
|
||||
- `frontend/src/components/explore-view/explore-view.ts:1270-1278` — "Searching…" and the error
|
||||
block are silent.
|
||||
- `frontend/src/components/track-list/track-list.ts:1901`, `1930-1933` — "Loading tracks…" /
|
||||
"No tracks match your search." with no `aria-live` and no `aria-busy` on the list.
|
||||
*Symptom:* a screen-reader user gets no feedback that a scan started or finished, that a setting
|
||||
saved, that a search returned nothing, or that the track changed.
|
||||
*Fix:* one `<div role="status" aria-live="polite" class="sr-only">` per surface, populated after the
|
||||
region already exists in the DOM.
|
||||
|
||||
**13. `frontend/src/components/artists-view/artists-view.ts:1059-1063` and `frontend/src/components/genres-view/genres-view.ts:947-951` — `aria-selected` on `role="button"` is invalid and dropped**
|
||||
Both cards render `role="button" aria-selected="${isSelected}"`. `aria-selected` is only valid on
|
||||
`gridcell`, `option`, `row`, `tab` and `treeitem`; on `button` it is ignored outright. These grids
|
||||
are genuinely multi-select (ctrl/shift-click via `SelectionController`).
|
||||
*Symptom:* selection state — the thing the whole ctrl/shift interaction exists to produce — is
|
||||
invisible to AT; visually it is a background-colour change only.
|
||||
*Fix:* `role="listbox" aria-multiselectable="true"` on the grid, `role="option" aria-selected` on
|
||||
the cards.
|
||||
|
||||
**14. `frontend/src/components/combobox/combobox.ts:288-303` — combobox has no `aria-controls` / `aria-activedescendant`**
|
||||
`role="combobox" aria-expanded aria-autocomplete="list"` on the input, `role="listbox"` on the `<ul>`,
|
||||
`role="option"` on the `<li>`s — but no `id` on the listbox, no `aria-controls`, no
|
||||
`aria-activedescendant`, and no `id` on the options. `aria-selected` is used to mean "highlighted"
|
||||
(302), not "chosen".
|
||||
*Symptom:* arrowing through suggestions moves the visual highlight but announces nothing; the user
|
||||
hears only their own typing.
|
||||
*Fix:* give the listbox and each option an `id`, add `aria-controls` and
|
||||
`aria-activedescendant=${optionId(highlightedIndex)}`.
|
||||
|
||||
**15. `frontend/src/components/now-playing/now-playing.ts:203-212, 391-408` — marquee text auto-scrolls with no reduced-motion guard and no pause**
|
||||
`transition: transform var(--scroll-duration, 5s) linear` re-armed in a loop by
|
||||
`onScrollCycleEnd`; when `scrollMode === 'always'` (persisted in localStorage, line 388-395) the
|
||||
title and artist scroll continuously for as long as the track plays. Only four files in the repo
|
||||
have a `prefers-reduced-motion` guard (`job-indicator.ts:126`, `job-row.ts:154`,
|
||||
`autotag-view.ts:471,599`) and this is not one of them.
|
||||
*Symptom:* WCAG 2.2.2 — moving content longer than 5s with no mechanism to pause it, and a
|
||||
vestibular-trigger risk with no reduced-motion opt-out.
|
||||
*Fix:* `@media (prefers-reduced-motion: reduce) { .scroll-content { transition: none } }` and treat
|
||||
`always` as `never` under that query.
|
||||
|
||||
**16. `frontend/src/components/config-page/config-page.ts:2091-2131` — the "Remove Library" confirmation is not a dialog**
|
||||
`<div class="cancel-dialog-overlay">` / `<div class="cancel-dialog">` with a
|
||||
`<div class="cancel-dialog-title">` — no `role="dialog"`, no `aria-modal`, no `aria-labelledby`, no
|
||||
focus move, no focus trap, no Escape handler, no focus restore. This gates deleting tracks,
|
||||
playlists and queue entries.
|
||||
*Symptom:* the destructive confirmation is never announced and can be Tab-escaped.
|
||||
*Fix:* same as finding 4 — `wa-dialog`, or role + trap + restore.
|
||||
|
||||
**17. `frontend/src/components/jobs/job-indicator.ts:378` — `role="dialog"` on an unmanaged popover**
|
||||
The panel declares `role="dialog"` (and the trigger `aria-haspopup="dialog"`, line 362) but nothing
|
||||
moves focus into it, traps Tab, handles Escape, or restores focus. It is a non-modal popover, not a
|
||||
dialog.
|
||||
*Symptom:* AT announces a dialog that never receives focus and cannot be dismissed by keyboard;
|
||||
tabbing past the trigger lands in the page behind while the panel is open.
|
||||
*Fix:* drop `role="dialog"` (use `role="group" aria-label="Background jobs"` and
|
||||
`aria-haspopup="true"`), or implement real dialog behaviour.
|
||||
|
||||
**18. `frontend/src/components/explore-view/explore-view.ts:1289-1305` — search-mode "tabs" convey the active mode by colour class only**
|
||||
`<button class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}">` — no
|
||||
`role="tab"`/`aria-selected`, no `aria-pressed`, no text or icon difference between active and
|
||||
inactive.
|
||||
*Symptom:* the user cannot tell whether they are searching the catalog or lyrics.
|
||||
*Fix:* `aria-pressed=${this.searchMode === 'catalog'}` (or a proper tablist).
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
**19. `frontend/src/styles/tokens.css.ts:18-22` — the entire type scale is hardcoded px**
|
||||
`--yj-text-xs: 11px` … `--yj-text-xl: 18px`, consumed by essentially every component. Combined with
|
||||
~50 further literal `font-size: Npx` declarations (e.g. `job-indicator.ts:138` at **9px**,
|
||||
`explore-view.ts:518` at 10px, `track-list.ts:901` at 10px, and inline
|
||||
`style="font-size: 12px"` at `queue-panel.ts:1518` and `playlist-view.ts:1865`).
|
||||
*Symptom:* text-only resize (WCAG 1.4.4) does nothing — a user who raises their OS/browser font size
|
||||
sees no change. 9-11px body text is below any reasonable floor to begin with.
|
||||
*Fix:* express the scale in `rem` so it tracks the root font size.
|
||||
|
||||
**20. `frontend/src/components/track-list/track-list.ts:972-985` and `frontend/src/components/queue-panel/queue-panel.ts:164-166` — fixed row heights with `contain: strict`**
|
||||
`.track-row { height: 33px; contain: strict }` and the matching virtualizer `_itemSize`
|
||||
(`track-list.ts:222`, `queue-panel.ts:165`, 49px). `contain: strict` clips overflow rather than
|
||||
growing the row.
|
||||
*Symptom:* any increase in text size (finding 19, or a user stylesheet) clips row text mid-glyph
|
||||
instead of reflowing; the virtualizer's scroll math also desynchronises.
|
||||
*Fix:* out of scope for a quick change, but at minimum document that the type scale and `_itemSize`
|
||||
are coupled.
|
||||
|
||||
**21. `frontend/index.css:12-20` — the app shell is `height: 100vh; overflow: hidden`**
|
||||
`body { height: 100vh; grid-template: "top-bar top-bar" 4em ... "bottom-bar bottom-bar" 4em; overflow: hidden }`.
|
||||
*Symptom:* at high zoom the 4em bars grow while the viewport does not, and anything that no longer
|
||||
fits is clipped with no scrollbar — WCAG 1.4.10 Reflow. The bottom bar's
|
||||
`grid-template-columns: var(--now-playing-width, 200px) 1fr auto` keeps a fixed 200px column while
|
||||
its text scales.
|
||||
*Fix:* allow the shell to scroll (`min-height: 100vh` + `overflow: auto`) below a breakpoint.
|
||||
|
||||
**22. `frontend/src/components/track-list/track-list.ts:1000-1017` — "now playing" and "selected" rows are colour-only**
|
||||
`.track-row.active { background-color: var(--yj-accent-bg); color: var(--yj-accent) }` and
|
||||
`.track-row.selected { background-color: var(--yj-selection-bg) }`; the row markup
|
||||
(`track-list.ts:1736-1745`) carries no `aria-current`, `aria-selected` or non-colour marker.
|
||||
*Symptom:* WCAG 1.4.1 — a colour-blind user cannot distinguish the playing row, and AT has no signal
|
||||
at all. Same pattern in `queue-panel.ts:1406-1409`.
|
||||
*Fix:* add a ▶ marker (or the existing play icon) to the active row and `aria-current="true"` once
|
||||
rows carry `role="row"`.
|
||||
|
||||
**23. `frontend/src/components/jobs/job-indicator.ts:150-156, 369` — the failure indicator is a bare 6px red dot**
|
||||
`<span class="alert-dot">` with `background: #ff6b6b` and no text, `aria-label` or `title`; the
|
||||
trigger's own name (`title="Background jobs"`, 363) does not change when it appears.
|
||||
*Symptom:* "a background job failed" is communicated by colour alone and not at all to AT.
|
||||
*Fix:* `<span class="alert-dot" role="img" aria-label="A background job failed"></span>`.
|
||||
|
||||
**24. Ellipsis truncation without `title` in the highest-density lists**
|
||||
`text-overflow: ellipsis` appears in 40+ places. `cover-grid.ts:1821,1832` and `home-view.ts:308`
|
||||
do add `title`; these do not:
|
||||
- `frontend/src/components/queue-panel/queue-panel.ts:389,401` (`.track-title`, `.track-artist`)
|
||||
vs. the markup at 1422-1428 — no `title`.
|
||||
- `frontend/src/components/track-info/track-info.ts:92,100` vs. markup at 118-126.
|
||||
- `frontend/src/components/track-list/track-list.ts:1018-1022` (`.cell`) vs. `1782-1788`.
|
||||
- `frontend/src/components/playlist-view/playlist-view.ts:355,360`.
|
||||
*Symptom:* long titles are clipped with no way to read the full value — acute in the queue panel,
|
||||
whose width is user-resizable down to `MIN_WIDTH`.
|
||||
*Fix:* `title=${value}` on the truncating element.
|
||||
|
||||
**25. `frontend/src/components/jobs/job-row.ts:270-272` — progress bar has no accessible name**
|
||||
`<wa-progress-bar value=...>`; Web Awesome renders `role="progressbar"` + `aria-valuenow`
|
||||
(`chunk.WDFK5BNW.js:42,47`) but no label is supplied.
|
||||
*Symptom:* announced as an unnamed "progress bar, 45%" with no indication of what is progressing.
|
||||
*Fix:* `aria-label=${job.title}` (or WA's `label` attribute).
|
||||
|
||||
**26. `frontend/src/components/search-bar/search-bar.ts:157-163` and `explore-view.ts:1308-1314` — search inputs are labelled by placeholder only**
|
||||
No `aria-label`, no `<label>`, no `role="searchbox"`, no `aria-describedby` pointing at the result
|
||||
count.
|
||||
*Fix:* `aria-label="Search library"` / `"Search catalog"`.
|
||||
|
||||
**27. `frontend/src/components/sidebar/app-sidebar.ts:202-241` — nav list has no landmark or item role** *(adjacent)*
|
||||
`<ul>` of `<li>` with `aria-current` (219) but no `role`, so `aria-current` sits on a
|
||||
non-interactive item and the whole thing is not inside a `<nav>` (`frontend/index.html:22` is a
|
||||
plain `<div class="sidebar">`).
|
||||
*Fix:* `<nav aria-label="Main">` in `index.html` and make each item a `<button>`/`<a>` — which also
|
||||
resolves the already-confirmed focusability gap.
|
||||
|
||||
**28. Mouse-only resize handles with no keyboard equivalent**
|
||||
`app-sidebar.ts:200`, `queue-panel.ts:1447`, `now-playing.ts:377`, and the track-list column
|
||||
resizers at `track-list.ts:1945-1953` are all `@mousedown`-only `<div>`s with no `role="separator"`,
|
||||
`tabindex` or arrow-key handling.
|
||||
*Symptom:* panel and column widths cannot be adjusted without a mouse. Low impact (cosmetic
|
||||
preference), but the pattern repeats four times.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
**29. `frontend/index.html:14-16` — heading hierarchy skips h1 → h3**
|
||||
`<h1 class="title">` immediately followed by `<h3 class="subtitle">`, styled at `0.8em`
|
||||
(`index.css:52-55`) — using a heading level for type size.
|
||||
*Fix:* make the subtitle a `<p>`.
|
||||
|
||||
**30. `frontend/index.html` — no skip link**
|
||||
`<main id="main-content">` exists (line 26) but nothing links to it, so keyboard users traverse the
|
||||
top bar and sidebar on every navigation.
|
||||
*Fix:* add a visually-hidden `<a href="#main-content">Skip to content</a>` as the first body child.
|
||||
|
||||
**31. `frontend/src/components/cover-grid/cover-grid.ts:509` — `<img>` with no `alt`**
|
||||
The only `alt`-less `<img>` in the codebase (every other one is either descriptive or correctly
|
||||
`alt=""`).
|
||||
*Fix:* `alt=""` if decorative.
|
||||
|
||||
**32. `frontend/src/components/queue-panel/queue-panel.ts:1431-1437` — per-row remove button is named by `title` only, and the name is not unique**
|
||||
`title="Remove from queue"` on every row provides an accname fallback, but it never identifies
|
||||
*which* track and is invisible to touch users.
|
||||
*Fix:* `aria-label="Remove ${track.title} from queue"`.
|
||||
|
||||
**33. `frontend/src/components/cover-grid/cover-grid.ts:1793-1797` — every album card is `tabindex="0"`** *(adjacent)*
|
||||
`role="button" tabindex="0"` on each virtualised card means the tab sequence length equals the number
|
||||
of rendered cards, with no roving tabindex. This is the opposite failure mode to the confirmed
|
||||
"only 14 tab stops" finding and will surface as soon as the other views are made focusable.
|
||||
*Fix:* roving tabindex (one `tabindex="0"`, the rest `-1`) once the grid gets `role="listbox"` per
|
||||
finding 13.
|
||||
|
||||
**34. `frontend/src/components/track-list/track-list.ts:900-901` — 10px sort arrow**
|
||||
`font-size: 10px; /* intentionally sub-token: tiny sort indicator */` — the comment acknowledges it.
|
||||
Combined with finding 9 (no `aria-sort`), the sort direction is a 10px glyph or nothing.
|
||||
|
||||
---
|
||||
|
||||
## What is already correct
|
||||
|
||||
- **`frontend/src/components/audio-player/controls/player-controls.ts:121-148`** — every transport
|
||||
button has an `aria-label`, shuffle and repeat carry `aria-pressed`, and repeat's three-state mode
|
||||
is spelled into the label (`Repeat: one`) rather than left to the CSS class. This is the model the
|
||||
rest of the app should follow.
|
||||
- **`frontend/src/components/audio-player/seekbar/seek-bar.ts:160-168`** and
|
||||
**`volume-control.ts:198`** — `wa-slider` with `aria-label` and a `valueFormatter`, so the seek
|
||||
position is announced as `3:42` rather than `222`.
|
||||
- **All five `wa-dialog` usages are genuinely modal and restore focus** — `track-details.ts:735`,
|
||||
`duplicate-tracks-dialog.ts:278`, `download-picker.ts:180`, `phantom-resolver.ts:927`,
|
||||
`first-run-wizard.ts:170`. Web Awesome's dialog uses native `showModal()`, `lockBodyScrolling` and
|
||||
`activeElement` restore (`chunk.ZUIYLL2X.js`), and every one of them passes a `label`. The
|
||||
hand-rolled dialogs in findings 4 and 16 are the outliers, and both have a working component to
|
||||
migrate to.
|
||||
- **`frontend/src/components/explore-artist-details/explore-artist-details.ts:2152, 2178, 2201, 2327, 2457`**
|
||||
— every disclosure toggle is a real `<button>` with `aria-expanded`, and the CSS keys off the
|
||||
attribute (`:465, :520, :680`) rather than a duplicate class. This is exactly the pattern
|
||||
`config-section.ts` (finding 1) is missing.
|
||||
- **`keyboard-shortcut-service.ts:73-83, 106-121`** — `getDeepActiveElement` correctly walks the
|
||||
shadow-root chain and `isTextInputFocused` covers `contentEditable` and the empty-`type` input
|
||||
case. The suppression logic is sound; the problems the parent already found are in *what* it does
|
||||
with the result, not in the resolution itself. Finding 5 is the autotag view failing to reuse it.
|
||||
- **`library-status-indicator.ts:186-196`** — status is conveyed by three distinct icons *and* a
|
||||
full sentence in both `title` and `aria-label`, and `handleKeydown` (175-180) stops Enter/Space
|
||||
from double-firing on the wrapping card. Correct on every axis.
|
||||
|
||||
---
|
||||
|
||||
## Residual risks / not covered
|
||||
|
||||
- Colour-contrast ratios were not measured (no rendering); the token palette
|
||||
(`--yj-text-tertiary: #888` on `--yj-bg-surface: #212529` ≈ 4.1:1) is borderline for the 11-12px
|
||||
text it is most often paired with, but that needs a real measurement.
|
||||
- `templ`-rendered HTMX fragments in `backend/config/` were out of scope and are not audited.
|
||||
- WebKit2GTK-specific behaviour (whether Ctrl+= page zoom is even reachable in the Wails shell, and
|
||||
how Orca traverses lit-virtualizer's windowed DOM) can only be confirmed on a running app.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Failure UX audit — YellowJacket
|
||||
|
||||
Scope: error handling, empty/loading states, destructive actions, and failure UX
|
||||
across the frontend/backend boundary. Read-only; nothing was changed.
|
||||
|
||||
Method: `backend/app.go`, every bound service in `FEBindings` (`backend/app.go:194-215`),
|
||||
the generated bindings under `frontend/wailsjs/go/**`, all 13 stores/controllers in
|
||||
`frontend/src/store/`, and every component in `frontend/src/components/` that calls a
|
||||
binding. Counts: 165 `catch` blocks in `frontend/src`, 84 of which end in
|
||||
`console.error`/`console.warn` and nothing else.
|
||||
|
||||
**Headline:** there is no application-level notification surface. Two components grew
|
||||
private, mutually-unaware toasts (`config-page.ts:1168`, `autotag-view.ts:1318`), and
|
||||
everything else logs to a console the user cannot open. The single most common failure
|
||||
in a music player — *this file will not play* — is one of the paths that reaches the
|
||||
user as complete silence.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. A track that fails to load or play is a silent no-op, forever
|
||||
**`backend/queue/queue.go:1181-1239`** (`loadCurrentTrack`, `playCurrentTrack`),
|
||||
reached from `Queue.Play/PlayIndex/Next/Previous/SetQueue`.
|
||||
|
||||
`LoadFile` or `Play` returning an error is logged and turns into `return false`; the
|
||||
caller reverts `currentIndex` (`queue.go:1069-1074`, `queue.go:920-925`) and returns.
|
||||
No event is emitted. Every Wails binding on the path returns `Promise<void>`
|
||||
(`frontend/wailsjs/go/queue/Queue.d.ts`) because the Go methods return nothing, so the
|
||||
frontend cannot even observe the failure — and `queue-store.ts:192-263` does not
|
||||
`await` or `.catch()` any of them regardless.
|
||||
|
||||
Symptom: double-click a track whose file was moved, is corrupt, or has an unsupported
|
||||
codec — nothing happens. No row highlight, no error, no skip. Double-click it again —
|
||||
still nothing. Mid-queue auto-advance onto a bad file stops playback dead with no
|
||||
explanation (`queue.go:920-925`), and pressing Next does nothing because Next hits the
|
||||
same bad track and reverts.
|
||||
|
||||
Fix: add a `PlaybackFailed` event carrying `{filePath, reason}`, emit it from
|
||||
`loadCurrentTrack`/`playCurrentTrack`, and have `Next`/auto-advance skip the failed
|
||||
track rather than reverting.
|
||||
|
||||
### C2. `SeekFailed` is emitted by the backend and nobody listens
|
||||
**`backend/player/player.go:776`** emits `events.SeekFailed`; **`frontend/src/events.ts:8`**
|
||||
declares it; there is no `EventsOn(Events.SeekFailed, ...)` anywhere in `frontend/src`
|
||||
(verified by grep — the only other hits are `events.go` and `emit_test.go`).
|
||||
|
||||
Symptom: dragging the seek bar on a track that has no loaded seeker snaps the thumb
|
||||
back to where it was, with no indication why.
|
||||
|
||||
Fix: subscribe in `player-store.ts` and surface it (revert the optimistic seek position
|
||||
plus a message), or delete the event so it stops implying coverage that does not exist.
|
||||
|
||||
### C3. Autotag apply writes to the user's files with no cancel, no undo, and no presence outside its own page
|
||||
**`backend/autotagservice/service.go:1078-1181`**, **`frontend/src/components/autotag-view/autotag-view.ts:1624-1662`**.
|
||||
|
||||
`ApplyAsync` spawns `go s.runApply(...)` which calls `s.applier.Apply(s.ctx, ...)` —
|
||||
it rewrites tags in place across a whole folder. There is:
|
||||
- no cancel (`grep 'jobs\.' backend/autotagservice/*.go` → nothing; it is not registered
|
||||
with the `jobs.Registry`, unlike scans, index builds and downloads),
|
||||
- no undo,
|
||||
- no visibility once the user leaves the autotag page — the progress lives entirely in
|
||||
`autotag-view`'s local `applyJobs` map (`autotag-view.ts:1180`), which is discarded on
|
||||
`disconnectedCallback` (`autotag-view.ts:1239`),
|
||||
- no drain on shutdown — `OnShutdown` (`backend/app.go:498-510`) saves player and queue
|
||||
state and returns; `OnBeforeClose` (`backend/app.go:461`) unconditionally returns
|
||||
`false`. Quitting mid-apply cancels `s.ctx` and leaves the folder half-retagged with
|
||||
nothing recording where it stopped.
|
||||
|
||||
Symptom: the user starts an apply, navigates away or quits, and comes back to a folder
|
||||
where some tracks carry the new tags and some the old, with no way to tell which.
|
||||
|
||||
Fix: register the apply with `jobs.Registry` (giving it the existing cancel/progress
|
||||
surface for free) and make `OnBeforeClose` return `true` while a file-writing job is in
|
||||
flight.
|
||||
|
||||
`backend/tagwriter/pipeline.go:286-360` (batch tag writes) has the same absence from
|
||||
the job registry, but is mitigated — see the note under **M8**.
|
||||
|
||||
### C4. `libraryStore` serves the previous library's data after a filter switch
|
||||
**`frontend/src/store/library-store.ts:339-343, 445-467, 128-152`**.
|
||||
|
||||
`setSelectedLibrary()` → `invalidate()` sets `this.tracks = null` and calls
|
||||
`eagerFetch()`. If the previous library's `GetAllTracksByLibrary` is still in flight,
|
||||
`getTracks()` sees `tracks === null && tracksLoading === true` and returns
|
||||
`waitForTracks()` (`library-store.ts:494`) — which waits for the *old* request. That
|
||||
request's `try` block then assigns `this.tracks = <library A's tracks>`
|
||||
(`library-store.ts:145`) and bumps `changeGen`, so the store is now caching A's tracks
|
||||
while `selectedLibraryIdValue` is B.
|
||||
|
||||
Symptom: switch the library filter twice quickly and the track/album/artist/genre lists
|
||||
show the wrong library's contents until the next scan or filter change.
|
||||
|
||||
Fix: stamp each fetch with a `fetchGen` captured at request time and drop the
|
||||
assignment when `fetchGen !== this.changeGen` (the same version-guard pattern
|
||||
`explore-view.ts:703/793/821` already uses correctly).
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1. A failed library fetch hangs every waiter forever
|
||||
**`frontend/src/store/library-store.ts:128-152, 494-506`** (and the identical
|
||||
`waitForAlbums`/`waitForArtists`/`waitForGenres` at 508-548).
|
||||
|
||||
`getTracks()` rejects → `finally` sets `tracksLoading = false` and notifies → the
|
||||
`waitForTracks` subscriber tests `!this.tracksLoading && this.tracks !== null`, which is
|
||||
false because `tracks` is still `null` → the promise never settles and the subscription
|
||||
is never removed.
|
||||
|
||||
Symptom: any component that called `getTracks()` while another fetch was in flight
|
||||
hangs on an unresolved promise (permanent spinner) and leaks a store subscription.
|
||||
|
||||
Fix: give the four `waitFor*` helpers a reject path, or store the in-flight promise and
|
||||
return it instead of re-deriving it from subscriber notifications.
|
||||
|
||||
### M2. The track list conflates "empty", "loading" and "failed" into one permanent "Loading tracks…"
|
||||
**`frontend/src/components/track-list/track-list.ts:1901-1902`**:
|
||||
`this.tracks.length === 0 ? html\`<p>Loading tracks...</p>\``.
|
||||
`loadTracks()` (`track-list.ts:1242-1257`) `console.error`s on failure and leaves
|
||||
`this.tracks` at `[]`.
|
||||
|
||||
Symptom: three different situations render as an infinite "Loading tracks…" —
|
||||
a genuinely empty library, a backend query that failed, and a library filter with
|
||||
nothing in it. `genre-details.ts:194-198` makes it worse: on error it sets
|
||||
`this.tracks = []` and hands that to `<track-list>`, so a failed genre query is
|
||||
indistinguishable from a slow one.
|
||||
|
||||
Fix: track `loading`/`error` as separate state and render three distinct bodies —
|
||||
the `home-view.ts:263-280` `renderBody()` is the correct model already in this repo.
|
||||
|
||||
### M3. The Settings search-index panel says "Loading status…" forever
|
||||
**`frontend/src/components/config-page/config-page.ts:186, 195, 1016-1022, 1034, 1530`**.
|
||||
|
||||
`indexStatus` is only ever assigned from the `IndexStatusChanged` event listener, and
|
||||
that event is emitted from exactly one place — `backend/explore/searchindex.go:692`,
|
||||
inside `emitStatus()`, which only fires on build status *mutations*. `indexPollTimer`
|
||||
is declared (195) and cleared (1034) but **never assigned**. The pull binding
|
||||
`GetIndexStatus()` exists (`frontend/wailsjs/go/explore/Service.d.ts:42`) and is never
|
||||
called from `frontend/src`.
|
||||
|
||||
Symptom: open Settings when no index build is running — which is the steady state —
|
||||
and the Search Index section shows "Loading status…" indefinitely, even though the
|
||||
index is fully built.
|
||||
|
||||
Fix: call `GetIndexStatus()` in `connectedCallback` to seed `indexStatus` before the
|
||||
first event arrives.
|
||||
|
||||
### M4. Job pause/resume/cancel failures are unhandled promise rejections
|
||||
**`frontend/src/components/jobs/job-controls.ts:17-35`**, wired as
|
||||
`@job-control=${applyJobControl}` at `jobs-view.ts:330`,
|
||||
`job-details-drawer.ts:335`, `job-indicator.ts:397`.
|
||||
|
||||
`applyJobControl` is `async` and is used directly as a DOM event listener, so its
|
||||
returned promise is discarded. `jobStore.pause/resume/cancel/dismiss`
|
||||
(`job-store.ts:189-204`) `await` the binding with no `catch`.
|
||||
|
||||
Symptom: press Pause on a scan and, if the backend rejects, the button does nothing —
|
||||
no state change, no message. There is also no in-flight guard, so double-clicking
|
||||
Cancel issues two `CancelJob` calls.
|
||||
|
||||
Fix: wrap the switch in try/catch inside `applyJobControl` and surface the failure;
|
||||
disable the row's controls until the next `JobsChanged` snapshot arrives.
|
||||
|
||||
### M5. Scan / full-rescan buttons fail silently
|
||||
**`frontend/src/components/jobs/jobs-view.ts:276-282, 284-290, 296-314`**.
|
||||
|
||||
All three handlers `console.error` and return. `FullRescan` returns
|
||||
`errNoLibrariesConfigured` when no library is configured
|
||||
(`backend/library/rescan.go:33-35`), and — unlike "Scan all", which is disabled on
|
||||
`this.libraries.length === 0` (`jobs-view.ts:434`) — the Full rescan button is only
|
||||
disabled on `anyScanning` (`jobs-view.ts:470`).
|
||||
|
||||
Symptom: with no libraries configured, the user reads a scary confirmation, clicks
|
||||
"Full rescan", confirms, and absolutely nothing happens.
|
||||
|
||||
Also a double-click hazard: `anyScanning` is derived from `jobStore`, which is fed by
|
||||
`JobsChanged` events coalesced at 250 ms (`backend/events` / `jobs` registry). Two
|
||||
clicks inside that window both issue `ScanLibrary`.
|
||||
|
||||
Fix: surface the error, add `|| this.libraries.length === 0` to the Full rescan
|
||||
`?disabled`, and add a local `starting` flag that disables the button until the job
|
||||
snapshot lands.
|
||||
|
||||
### M6. Deleting a playlist has no confirmation and no undo
|
||||
**`frontend/src/components/playlist-view/playlist-view.ts:1352-1372`** (multi-select
|
||||
path) and **`1381-1392`** (`handleDeletePlaylist`).
|
||||
|
||||
The multi-select branch loops `await DeletePlaylist(id)` over every selected playlist
|
||||
with no prompt. `handleDeletePlaylist` `console.error`s on failure, so a partial
|
||||
failure looks like a success until the refresh reveals the playlist is still there.
|
||||
|
||||
Compare `jobs-view.ts:296` (full rescan) and `job-controls.ts:41-53` (index cancel),
|
||||
both of which do confirm — the codebase has the convention, this path just skips it.
|
||||
|
||||
Fix: `window.confirm` naming the playlist(s) and their track counts, matching the
|
||||
pattern already used for full rescan.
|
||||
|
||||
### M7. Durable download requests are removed with one click, no confirmation, unhandled rejection
|
||||
**`frontend/src/components/downloads-view/downloads-view.ts:466-476`**
|
||||
(`void downloadStore.removeRequest(request.id)`), and the same shape at
|
||||
**`451-460`** (`pauseRequest`) and **`296-300`** (`clearSatisfiedRequests`).
|
||||
|
||||
`downloadStore.removeRequest` (`download-store.ts:434-437`) awaits `RemoveRequest` with
|
||||
no catch, and the call site discards the promise with `void`.
|
||||
|
||||
Symptom: click the ✕ next to an artist subscription you have been building for months
|
||||
— it disappears with no prompt and no undo; or, if the delete fails, it stays put with
|
||||
no explanation.
|
||||
|
||||
Fix: confirm before removing a subscription, and `.catch()` the promise into a visible
|
||||
message.
|
||||
|
||||
### M8. Stale preview overwrites newer rules in the smart-playlist editor
|
||||
**`frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts:666-707`**.
|
||||
|
||||
`schedulePreview()` debounces 300 ms, then `runPreview()` awaits
|
||||
`PreviewSmartPlaylist(json)` with no request id. Debouncing only coalesces keystrokes
|
||||
*within* the window; a query that takes longer than 300 ms overlaps the next one, and
|
||||
whichever resolves last wins.
|
||||
|
||||
Symptom: edit a rule, and the preview list settles on the results of the *previous*
|
||||
rule set. The `finally` block also clears `previewLoading` from the stale response,
|
||||
so the spinner stops while the current query is still running.
|
||||
|
||||
Fix: capture `const v = ++this.previewVersion` and bail on
|
||||
`if (v !== this.previewVersion) return` in both the success and `finally` paths —
|
||||
`explore-view.ts:703/793/821/826` does exactly this correctly.
|
||||
|
||||
### M9. Raw Go error strings are rendered to the user in six places
|
||||
No error is ever mapped to human copy. Verbatim `err.Error()` / `String(err)` reaches
|
||||
the UI at:
|
||||
|
||||
| Location | What the user sees |
|
||||
|---|---|
|
||||
| `explore-album-details.ts:1755, 1811` (set at `912, 969`) | `Get "https://musicbrainz.org/ws/2/…": context deadline exceeded` |
|
||||
| `explore-artist-details.ts:2023, 2296` (set at `1294, 1466`) | same class of string |
|
||||
| `explore-view.ts:1276` (set at `823, 848`) | same |
|
||||
| `config-page.ts:1142` | `Failed to remove 'Music': sql: database is locked` |
|
||||
| `config-page.ts:1514` | index tier `${t.error}` verbatim |
|
||||
| `autotag-view.ts:1289, 1651` | `Apply failed: build plan: …` |
|
||||
| `download-picker.ts:141, 159`; `download-clients.ts:645, 668, 690` | `String(err)` verbatim |
|
||||
| `first-run-wizard.ts:239, 259` | `Could not add the folder: ${err}` |
|
||||
|
||||
These come straight out of `musicbrainzws2` / `net/http` / `database/sql`
|
||||
(`backend/explore/musicbrainz.go:267-285` returns the client error unwrapped), so the
|
||||
string is a Go stack-flavoured HTTP error, not a sentence.
|
||||
|
||||
Fix: introduce a small `describeError(err)` helper in `frontend/src/utils/` that maps
|
||||
the handful of recognisable cases (offline, timeout, not found, permission) to copy and
|
||||
falls back to a generic line, and route all eight sites through it. Keep the raw text
|
||||
in `console.error` for debugging.
|
||||
|
||||
Genuine counter-example worth preserving: `download-store.ts:337-341` deliberately lets
|
||||
`TestProvider`'s message through, and documents why — that one is the user's debugging
|
||||
tool for a misconfigured client. That is the exception, not the rule.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1. Every queue and player action is fire-and-forget
|
||||
**`frontend/src/store/queue-store.ts:192-266`**, **`frontend/src/store/player-store.ts:96-114`**.
|
||||
|
||||
Twenty binding calls (`Queue.Play`, `Queue.SetQueue`, `Queue.Clear`, `Queue.RemoveTracks`,
|
||||
`Player.Pause`, `Player.LoadFile`, `Player.Seek`, `Player.SetVolume`, …) are invoked
|
||||
with no `await`, no `.catch()`, and no `void`. Wails still returns a promise, so a
|
||||
rejection (which happens if the bridge is torn down, or the arg fails to marshal)
|
||||
becomes an unhandled rejection.
|
||||
|
||||
Mostly benign today because the Go methods return nothing (see **C1**), but it means
|
||||
these methods cannot report failure even after C1 is fixed.
|
||||
|
||||
Fix: as part of the C1 fix, change the queue methods to return `error` and have the
|
||||
store `.catch()` them.
|
||||
|
||||
### m2. Favorite toggles revert silently
|
||||
**`frontend/src/store/favorites-store.ts:137-158`** (and `160-190` for the batch forms).
|
||||
|
||||
The optimistic update and its revert are both correct, but the revert is invisible.
|
||||
|
||||
Symptom: click the heart, it fills, and half a second later it empties again with no
|
||||
explanation.
|
||||
|
||||
Fix: on the revert path, surface a one-line message.
|
||||
|
||||
### m3. Clearing the queue has no confirmation and no undo
|
||||
**`frontend/src/components/queue-panel/queue-panel.ts:683-685`** →
|
||||
`queue-store.ts:262` → `backend/queue/queue.go:1138`, which stops playback and
|
||||
discards the list.
|
||||
|
||||
Not catastrophic (the queue is reconstructable), but it is the only mutation in the
|
||||
panel with no way back, and it sits next to routine controls.
|
||||
|
||||
Fix: either confirm when the queue is non-trivially long, or keep the last cleared
|
||||
queue in memory behind an "Undo" affordance.
|
||||
|
||||
### m4. Removing a download client provider has no confirmation
|
||||
**`frontend/src/components/config-page/download-clients.ts:684-692`**.
|
||||
Deleting a provider discards its stored credentials
|
||||
(`backend/download`'s `FileSecretStore`), which cannot be recovered.
|
||||
|
||||
Fix: confirm, naming the client.
|
||||
|
||||
### m5. `AddLibrary` / `RenameLibrary` failures are console-only
|
||||
**`frontend/src/components/config-page/config-page.ts:1058-1072`** (add),
|
||||
**`1082-1097`** (rename). Both `console.error`. Note that *removal* — the more
|
||||
dangerous operation — is handled correctly in the same file (impact preview at
|
||||
`1105-1114`, confirmation, `isRemoving` guard, toast at `1129-1143`).
|
||||
|
||||
Fix: route these two through the existing `showToast` (`config-page.ts:1168`).
|
||||
|
||||
### m6. Autotag warning/skip/leave dialogs stall on a rejected binding
|
||||
**`frontend/src/components/autotag-view/autotag-view.ts:1328-1334`**
|
||||
(`onWarningContinue` → `await AckLibraryWarning(...)`),
|
||||
**`1336-1342`** (`onLeaveConfirm` → `await LeaveAsIs(...)`),
|
||||
**`1660-1664`** (`onSkip` → `await Skip(...)`).
|
||||
|
||||
None is wrapped. A rejection means the lines after the await — including
|
||||
`this.dialog = 'none'` — never run.
|
||||
|
||||
Symptom: press "Continue" on the destructive-write warning and the dialog just sits
|
||||
there.
|
||||
|
||||
Fix: try/catch each, close the dialog in a `finally`, and surface the error.
|
||||
|
||||
### m7. Add-to-playlist fails silently after a correct in-flight guard
|
||||
**`frontend/src/components/playlist-picker/playlist-picker.ts:164-193, 216-231`**.
|
||||
|
||||
The `this.loading` guard is right (no double-add), the create button is disabled while
|
||||
in flight (`playlist-picker.ts:321`) — but the failure path is `console.error` and the
|
||||
picker just closes.
|
||||
|
||||
Symptom: the tracks appear not to have been added, and the user cannot tell whether to
|
||||
retry.
|
||||
|
||||
Same shape at `playlist-details.ts:396-412` (remove tracks), `414-438` (remove
|
||||
phantoms), `584-601` (remove one phantom).
|
||||
|
||||
### m8. The download search cannot be cancelled
|
||||
**`frontend/src/components/download-picker/download-picker.ts:127-148`**.
|
||||
|
||||
`downloadStore.start()` queries every enabled provider. The dialog shows a spinner and
|
||||
"Searching your download clients…" but the only exit is Close, which does not cancel
|
||||
the backend work. `search()` also has no stale guard, so a close-and-reopen for a
|
||||
different album can be overwritten by the first search's result.
|
||||
|
||||
Otherwise this file is the strongest failure UX in the codebase — see **What is
|
||||
already right** below.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1. `console.log` debug output left in shipped views
|
||||
`explore-album-details.ts:667, 673, 680, 695, 715, 877`;
|
||||
`explore-artist-details.ts:1017, 1030, 1037, 1071`;
|
||||
`config-page.ts:1019` (`'IndexStatusChanged event received'`).
|
||||
|
||||
### p2. Long-running operation coverage is inconsistent by subsystem
|
||||
|
||||
| Operation | Progress | Cancel | Pause/resume | Survives quit |
|
||||
|---|---|---|---|---|
|
||||
| Library scan | ✅ jobs registry | ✅ | ✅ | ✅ paused scans restored (`backend/library/scan_jobs.go:300`) |
|
||||
| Index build | ✅ | ✅ (confirmed, `job-controls.ts:41`) | ✅ | ✅ checkpointed |
|
||||
| Downloads | ✅ (`download/manager.go:192`) | ✅ | — | ✅ swept on restart |
|
||||
| Batch tag write | ✅ event | ✅ (`track-details.ts:1767`) | — | ❌ not in registry |
|
||||
| **Autotag apply** | ⚠️ page-local only | ❌ | ❌ | ❌ (see **C3**) |
|
||||
| **Download search** | spinner | ❌ | — | ❌ (see **m8**) |
|
||||
| **Requests reconcile** | `checking` flag (`downloads-view.ts:503`) | ❌ | — | — |
|
||||
|
||||
The pattern is clear: everything routed through `jobs.Registry` gets progress, cancel
|
||||
and a global indicator for free. The three gaps are the three things not registered.
|
||||
|
||||
### p3. `EventsOff` is global
|
||||
**`frontend/src/components/track-details/track-details.ts:1765`** calls
|
||||
`EventsOff(Events.BatchWriteProgress)`, which removes *all* listeners for that event,
|
||||
not just this component's. Correct today (single listener) but fragile; prefer the
|
||||
unsubscribe function `EventsOn` returns, as `jobs-view.ts:246-249` does.
|
||||
|
||||
### p4. `OnBeforeClose` never asks
|
||||
**`backend/app.go:445-484`** always returns `false`. Quitting during a full rescan
|
||||
leaves the library partially rebuilt — recoverable, because the soft scan re-runs on
|
||||
next launch (`backend/app.go:568`), but playlists are not restored until that scan
|
||||
completes (`RestoreAllPlaylists` only runs from the `PostScan` hook,
|
||||
`backend/app.go:341`). Worth a confirm while a destructive job is running.
|
||||
|
||||
---
|
||||
|
||||
## What is already right (keep these as the templates)
|
||||
|
||||
- **`frontend/src/components/download-picker/download-picker.ts`** — distinct
|
||||
searching / auto-picked / empty ("Nothing found. Try a different spelling…") /
|
||||
error bodies, an in-flight `picking` guard on `onPick` (`154`), and a footnote that
|
||||
explains *why* it is asking rather than deciding (`243-262`). This is the standard
|
||||
the rest of the app should be measured against.
|
||||
- **`frontend/src/components/home-view/home-view.ts:263-280`** — the only place that
|
||||
correctly distinguishes loading, failed, and genuinely-empty in three separate
|
||||
bodies.
|
||||
- **`frontend/src/components/explore-view/explore-view.ts:703, 793, 820-828`** — a
|
||||
correct monotonic request-version guard on search-as-you-type, checked on the success
|
||||
path, the catch path *and* the `finally` that clears the spinner. This is the fix
|
||||
pattern for **C4** and **M8**.
|
||||
- **`frontend/src/components/track-details/track-details.ts:1706-1766`** — the best
|
||||
destructive flow in the app: an explicit change summary, a confirmation step, live
|
||||
per-file progress, a working cancel, and a per-file failure list afterwards.
|
||||
- **`frontend/src/components/config-page/config-page.ts:1105-1143`** — removal shows a
|
||||
computed impact (`GetRemovalImpact`) *before* asking, guards with `isRemoving`, and
|
||||
reports the outcome. The right shape; only the raw error string (**M9**) lets it down.
|
||||
- **`frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts`** — a
|
||||
purpose-built component whose entire job is to admit what the user is looking at, with
|
||||
Retry offered only in the one scope where retrying means anything.
|
||||
- **`backend/library/scan_jobs.go:265-345`** — paused scans survive a restart, and a
|
||||
pause that outlived the process resumes as an incremental rescan with a log line
|
||||
saying so.
|
||||
- **`frontend/src/store/favorites-store.ts:137-158`** — optimistic update with a
|
||||
correct revert. Only the silence (**m2**) is wrong.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **C1** + **C2** — playback failure is the app's core job; it currently fails mute.
|
||||
2. A minimal app-level notification surface, then route **M9**'s eight sites,
|
||||
**M5**, **M6**, **M7**, **m2**, **m5**, **m7** through it. Most of these findings
|
||||
are one problem wearing thirty hats.
|
||||
3. **M3**, **M2** — two permanent fake "loading" states.
|
||||
4. **C4** + **M1** + **M8** — the three async-correctness bugs; all three are the same
|
||||
version-guard fix, and `explore-view.ts` already contains the reference
|
||||
implementation.
|
||||
5. **C3** — register the autotag apply with `jobs.Registry` and it inherits progress,
|
||||
cancel and the global indicator at once.
|
||||
@@ -0,0 +1,259 @@
|
||||
# UI/UX audit — YellowJacket
|
||||
|
||||
Date: 2026-08-11. Method: the app driven by hand headlessly
|
||||
(`make dev-headless SEED=default` + `playwright-cli`, then
|
||||
`make dev-headless-fresh` for first run), plus three read-only static
|
||||
reviews. Nothing was changed.
|
||||
|
||||
- `hands-on.md` (this file) — the empirically confirmed findings, i.e.
|
||||
things observed happening in the running app, with the reproduction.
|
||||
- `a11y.md` — accessibility and interaction model.
|
||||
- `perf.md` — rendering performance, memory, state correctness.
|
||||
- `errors.md` — error handling, empty/loading states, destructive actions.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical — confirmed by reproduction
|
||||
|
||||
### H-1. A keypress on any page silently mutates the Autotag queue
|
||||
|
||||
Every view the user visits stays mounted forever (`index.ts`, class
|
||||
`view-hidden`), so `disconnectedCallback` never runs and
|
||||
`autotag-view`'s `document` keydown listener (`autotag-view.ts:1188`,
|
||||
handler at `:1706`) stays live for the rest of the session.
|
||||
|
||||
Reproduced: visited Autotag (Pending 11), navigated to Settings,
|
||||
dispatched `keydown` `s` twice → **Pending 9**. Two albums skipped from
|
||||
a page that was not on screen and gave no feedback. `a` on the same
|
||||
listener is Apply, which rewrites tags on disk.
|
||||
|
||||
### H-2. `s` and the arrow keys fire two handlers at once
|
||||
|
||||
`autotag-view`'s listener and `keyboard-shortcut-service` are both on
|
||||
`document` and neither defers. Reproduced on the Autotag page: pressing
|
||||
`s` emitted `QueueModeChanged` (shuffle toggled) *and* skipped the
|
||||
album. `ArrowUp`/`ArrowDown` navigate the folder list *and* change the
|
||||
volume by 5, so walking the autotag list with the keyboard ramps volume
|
||||
to 0 or 100.
|
||||
|
||||
### H-3. The progress bar is a local timer that lies, and a keyboard seek desyncs it by ~30 s
|
||||
|
||||
`seek-bar.ts:110-116` increments `seekValue` by 1 every 1000 ms and only
|
||||
resyncs when `trackChangeId` changes. Nothing reconciles it against
|
||||
`Player.CurrentPositionSeconds`.
|
||||
|
||||
Reproduced twice:
|
||||
|
||||
| | UI | backend |
|
||||
|---|---|---|
|
||||
| steady playback, +10 s | 00:47 → 00:57 | 50 → 60 (constant 3 s lie) |
|
||||
| after 4× `ArrowRight` (seek +5 s) | 00:08 → **00:10** | 11 → **40** |
|
||||
|
||||
The keyboard seek path (`keyboard-shortcut-service.ts:207-214`) calls
|
||||
`Player.Seek` and never tells the seek bar, so the bar does not move at
|
||||
all — the shortcut looks broken, and the displayed time is wrong for
|
||||
the rest of the track.
|
||||
|
||||
### H-4. Every icon in the app is fetched from fontawesome.com at runtime
|
||||
|
||||
Confirmed from `performance.getEntriesByType('resource')`:
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/solid/house.svg`
|
||||
and 35 more. `setBasePath('/dist/webawesome')` in `index.ts` does not
|
||||
affect the icon resolver, and no `registerIconLibrary` call exists.
|
||||
A desktop music player offline, on a captive portal, or behind a
|
||||
firewall renders **no icons at all**. See `perf.md` M9.
|
||||
|
||||
### H-5. The whole app is unusable without a mouse
|
||||
|
||||
Tabbing through the entire app yields **14 stops**, all of them chrome
|
||||
(library filter, search, one unlabelled track-list button, two queue
|
||||
buttons, five transport buttons, volume, queue toggle, seek). The
|
||||
sidebar nav (`app-sidebar.ts:202`, bare `<li @click>`), every track
|
||||
row, every album/artist/genre card and every context menu are
|
||||
unreachable. `Enter` on a selected track does nothing — reproduced.
|
||||
|
||||
The cause of the last part is that `data-shortcut-scope` is **never set
|
||||
anywhere in the codebase**, so `resolveScope` can only return
|
||||
`text-input` or `global`, and the two panel-scoped bindings
|
||||
(`tracklist.play` = Enter, `tracklist.delete` = Delete) are dead
|
||||
shortcuts that the Settings page still advertises as configurable.
|
||||
|
||||
Related: the closed queue panel is `width: 0` but not `inert` and not
|
||||
`visibility: hidden` (`queue-panel.ts:214`), so its Clear/Add buttons
|
||||
still take tab stops and are read by screen readers — reproduced, they
|
||||
appear in the tab order at x=1440.
|
||||
|
||||
---
|
||||
|
||||
## Major — confirmed by reproduction
|
||||
|
||||
### H-6. Global single-key shortcuts hijack keys from focused controls
|
||||
|
||||
Defaults (`backend/shortcuts/shortcuts.go:16`) bind unmodified
|
||||
`Space N P S R M / Q ↑ ↓ ← →` at global scope, and the service calls
|
||||
`preventDefault()` on a match. Only text inputs are exempt. So a
|
||||
focused `<button>` cannot be activated with Space, the native
|
||||
`<select>` library filter cannot be arrowed through, the volume and
|
||||
seek sliders fight the global handler for arrow keys, and Space/arrow
|
||||
page scrolling is dead everywhere.
|
||||
|
||||
`ArrowUp` also emits `MuteChanged` alongside `VolumeChanged` even when
|
||||
nothing is muted — reproduced.
|
||||
|
||||
### H-7. The last column of the track list is always clipped by exactly 40 px
|
||||
|
||||
`computeDefaultWidths` (`track-list.ts:409`) distributes
|
||||
`this.clientWidth` across the columns but never subtracts the 24 px
|
||||
favourite column or the 2×8 px row padding that
|
||||
`colBoundaryPositions` (`:378`) knows about. Measured: every
|
||||
`.track-row` and the `.header-row` report `scrollWidth 1280` against
|
||||
`clientWidth 1240`. Duration renders as "Durat…" on a fresh profile at
|
||||
1440×900, and disappears entirely below ~1000 px.
|
||||
|
||||
### H-8. The app never lands on Home
|
||||
|
||||
`app-sidebar.ts:124` defaults `activeView = 'tracks'`. The curated Home
|
||||
page — the one with the "somewhere to start listening" shelves — is
|
||||
listed first in the nav and is never what the user sees on launch.
|
||||
|
||||
### H-9. On the Home page, an album with no cover art renders as nothing
|
||||
|
||||
The Home shelf card's missing-art placeholder has no background, so the
|
||||
tile is invisible against the page and the shelf reads as having holes
|
||||
in it. The Albums grid and the Artists grid both do this correctly
|
||||
(letter-on-a-tile), so this is one card renderer disagreeing with the
|
||||
other two.
|
||||
|
||||
Also on Home: with a small library all three shelves ("Fresh in your
|
||||
library", "Never played", "Take a chance") show the **same seven
|
||||
albums** in different orders, so the page reads as repeating itself.
|
||||
A shelf whose contents largely duplicate the shelf above it would be
|
||||
better suppressed, the way an empty one already is.
|
||||
|
||||
### H-10. The header search is view-scoped but looks global
|
||||
|
||||
Typing `tide` on the Playlists page produced **"No playlists match your
|
||||
search"** while three tracks named *Tideline* sat in the library. The
|
||||
box is in the global header, is placeheld "Search…", and persists its
|
||||
term across navigation, so it reads as a library-wide search and is
|
||||
not one. It also vanishes entirely on Home and Explore (Explore has its
|
||||
own second search box), and its appearing/disappearing shifts the whole
|
||||
header layout.
|
||||
|
||||
### H-11. The layout has no responsive behaviour and the enforced minimum window is too small
|
||||
|
||||
`MinWidth/MinHeight` are 512×384 (`backend/config/window.go:15`). At
|
||||
900×600 the Duration column is off-screen; at 700×480 the sidebar
|
||||
overflows behind the player bar with no scroll, so **Settings and Jobs
|
||||
become unreachable**, and the app title wraps into the nav. The sidebar
|
||||
has a `.collapsed` icon mode but nothing triggers it automatically.
|
||||
|
||||
### H-12. First run shows "Loading tracks…" behind an inert copy of the whole app
|
||||
|
||||
On an empty `YJ_HOME` the wizard is a modal over a fully rendered app —
|
||||
sidebar, transport, search, library filter all visible and all inert —
|
||||
with a permanent "Loading tracks…" in the content area (the track list
|
||||
cannot tell empty from loading, `track-list.ts:1901`). Meanwhile the
|
||||
"Building search index" job is already downloading a 1.1 M-row catalog
|
||||
before the user has chosen a folder or consented to it.
|
||||
|
||||
`Get Started` is correctly disabled until a folder is chosen, but it is
|
||||
the filled accent button and its disabled state is barely visible.
|
||||
|
||||
### H-13. The album detail page has no way to play the album
|
||||
|
||||
The primary action is missing: no Play, no Shuffle, no Add to queue on
|
||||
the album header. Nor is there any legend for the green ✓ badges shown
|
||||
against the album title and every track.
|
||||
|
||||
### H-14. `IndexStatusChanged` is emitted every 3 seconds forever
|
||||
|
||||
`searchindex.go:276` starts an unconditional 3 s ticker in
|
||||
`SetContext` and never stops it. The payload is byte-identical once the
|
||||
index is ready (`building:false, ready:true`) and it keeps firing for
|
||||
the life of the process. Each tick re-renders the 2 149-line
|
||||
`config-page` (which never unmounts) and writes a `console.log`
|
||||
(`config-page.ts:1019`) — the browser console filled with ~200
|
||||
identical lines during a 20-minute session. See `perf.md` M6.
|
||||
|
||||
---
|
||||
|
||||
## Minor — confirmed by observation
|
||||
|
||||
- **H-15.** Three identical `Tideline / Aurora Fields / 00:06` rows are
|
||||
indistinguishable in the track list; the default columns carry no
|
||||
album, format or path, so the app's own duplicate fixtures cannot be
|
||||
told apart by eye in a library manager that has a duplicate-detection
|
||||
feature.
|
||||
- **H-16.** The remaining-time label is a countdown with no minus sign,
|
||||
no label and no toggle to total duration — `01:21` next to a track
|
||||
the list says is `01:30`.
|
||||
- **H-17.** The now-playing artist is truncated to a fixed ~120 px
|
||||
("The Orchestra Of") while ~400 px of empty space sits between it and
|
||||
the transport controls.
|
||||
- **H-18.** When a queue finishes, the now-playing bar empties
|
||||
completely, losing the context of what just played, while the queue
|
||||
panel still lists the finished track.
|
||||
- **H-19.** Page headings are inconsistent: Playlists, Downloads, Jobs,
|
||||
Settings and Home have a title (and Playlists/Downloads/Jobs have
|
||||
header actions); Artists, Genres, Albums and Tracks have none, and
|
||||
none of them shows a count. Sort controls exist on Albums and Tracks
|
||||
but not on Artists or Genres.
|
||||
- **H-20.** The sidebar's hover colour (`#343a40`) and its active
|
||||
colour (`#495057`) are close enough that a hovered item reads as a
|
||||
second selected item.
|
||||
- **H-21.** The track context menu has no Escape handler, no keyboard
|
||||
navigation and no focus movement (`context-menu-controller.ts` binds
|
||||
only click/contextmenu/mousedown), and is missing the conventional
|
||||
entries: Go to album, Go to artist, Show in file manager, Edit tags,
|
||||
Remove from library.
|
||||
- **H-22.** In Settings, "Libraries" — the section that matters most —
|
||||
is last and below the fold, while "Search Index" is first and
|
||||
expanded by default. There is no Playback/Audio section at all (no
|
||||
output device, gapless, crossfade or replay gain).
|
||||
- **H-23.** Explore is an empty page with a search box over a 1.1 M-row
|
||||
catalog: no browse, no popular-artists entry point, nothing to do
|
||||
without typing.
|
||||
- **H-24.** Long body copy (Downloads' intro, Jobs' descriptions) runs
|
||||
the full ~1200 px content width with no measure cap.
|
||||
|
||||
---
|
||||
|
||||
## Where the bar is already high
|
||||
|
||||
Worth naming, because the findings above are the exceptions:
|
||||
|
||||
- **`downloads-view`** — the best empty state in the app: it says what
|
||||
the feature is, why nothing is happening, and exactly what to do next.
|
||||
- **`autotag-view`** — genuinely dense and legible: per-field match
|
||||
breakdown, your-folder-vs-candidate side by side, confidence stated
|
||||
rather than hidden.
|
||||
- **`jobs-view`** — running / libraries / maintenance / recently
|
||||
finished, with the destructive action visually separated and honestly
|
||||
described.
|
||||
- **`track-list`** — a properly built virtualized list (memoized
|
||||
filter/sort, delegated handlers, `_itemSize` hint, inline SVG for the
|
||||
per-row icon). Its problems are at the edges, not in the core.
|
||||
- **`player-controls`** — every button labelled, `aria-pressed` on the
|
||||
toggles, repeat's three-state mode spelled into the label.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **H-1 / H-2** — a hidden page mutating files on a keystroke is the
|
||||
only finding here that loses user data. Fix the view lifecycle
|
||||
(deactivate hidden views) and make the two keydown listeners agree.
|
||||
2. **H-3** — drive the seek bar from the backend position; the core
|
||||
surface of a music player currently lies.
|
||||
3. **H-4** — bundle the icons; the app is not usable offline.
|
||||
4. `errors.md` **C1** — a track that fails to play is a silent no-op,
|
||||
which is the same class of problem as H-3 on the same surface.
|
||||
5. **H-5 / H-6** — keyboard access, and stop the global shortcuts
|
||||
stealing keys from focused controls.
|
||||
6. **H-7 / H-11** — the layout arithmetic and a real minimum size.
|
||||
7. Then the consistency pass: **H-8, H-9, H-10, H-13, H-19**.
|
||||
@@ -0,0 +1,505 @@
|
||||
# Frontend performance / memory / state-correctness audit
|
||||
|
||||
**Scope:** `frontend/src/store/**`, `frontend/src/components/**`, `frontend/src/events.ts`,
|
||||
`frontend/vite.config.mts`, `frontend/package.json`, `frontend/index.ts`, `frontend/index.html`.
|
||||
Read-only. Nothing in the repo was modified. (Two throwaway production builds were emitted to
|
||||
`/tmp/yjbuild*` to measure bundle composition; `frontend/dist/` was not touched.)
|
||||
|
||||
**Excluded as already-known** (traced for consequences, not re-reported): views never unmount,
|
||||
`autotag-view`'s document keydown, `IndexStatusChanged` every 3 s, seek-bar drift.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1 — Finishing a track re-downloads the entire library
|
||||
|
||||
`backend/queue/playhistory.go:63` → `frontend/src/store/library-store.ts:85` → `:445`
|
||||
|
||||
`recordPlay()` emits `TrackMetadataChanged` on **every naturally finished track**
|
||||
(`backend/queue/handlers.go:24,34,45,52`). `LibraryStore` treats that event exactly like a retag:
|
||||
`invalidate()` nulls tracks/albums/artists/genres and immediately `eagerFetch()`es all four
|
||||
(`library-store.ts:445-476`). On a 50 k-track library that is `GetAllTracks` +
|
||||
`GetAllAlbums` + `GetAllArtists` + `GetAllGenresWithCounts` — roughly 25 MB of JSON across the
|
||||
Wails IPC, parsed on the main thread — **once per song**, forever, whether or not the user is
|
||||
looking at a list.
|
||||
|
||||
The invalidation itself is correct and deliberate (`frontend/test/stores/library-store.test.ts:94-110`
|
||||
asserts it); the defect is that the backend reuses one event for "tags were rewritten" and
|
||||
"play_count went up by one".
|
||||
|
||||
*Symptom:* a multi-second main-thread stall between every two tracks on a large library, plus
|
||||
constant SQLite churn.
|
||||
*Fix:* emit a distinct `TrackPlayCountChanged` from `recordPlay` and have `LibraryStore` patch the
|
||||
one track in place instead of invalidating.
|
||||
|
||||
### C2 — …and silently wipes the user's selection while it does
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1198-1211` → `:1242-1246`
|
||||
|
||||
`updated()` notices `libraryCtrl.cachedTracks` has a new identity and calls `loadTracks()`, which
|
||||
does `this.selection.clear()` (`:1246`). Combined with C1, **every track change clears whatever the
|
||||
user had selected in the track list.** Selecting 40 tracks to drag into a playlist while music plays
|
||||
is not possible.
|
||||
|
||||
*Fix:* re-key the selection against the new array (`selection` is keyed by `FilePath`, which
|
||||
survives a refetch) instead of clearing it.
|
||||
|
||||
### C3 — Library-filter / rescan race caches the wrong library's data
|
||||
|
||||
`frontend/src/store/library-store.ts:133-155` (and the identical `getAlbums`/`getArtists`/`getGenres`)
|
||||
|
||||
`getTracks()` guards on `tracksLoading`, but `invalidate()` (`:445`) clears `tracks` **without**
|
||||
clearing `tracksLoading`. Sequence:
|
||||
|
||||
1. `getTracks()` starts for library A → `tracksLoading = true`.
|
||||
2. User picks library B → `setSelectedLibrary` (`:339`) → `invalidate()` → `tracks = null`,
|
||||
`eagerFetch()` → `getTracks()` sees `tracks === null && tracksLoading === true` → returns
|
||||
`waitForTracks()`.
|
||||
3. Library A's response lands, is stored as `this.tracks`, `changeGen++`.
|
||||
4. `waitForTracks()` resolves with library A's tracks — under library B's filter.
|
||||
|
||||
The same window exists for `LibraryScanComplete` arriving while a fetch is in flight, in which case
|
||||
the pre-scan snapshot is cached as if it were post-scan and the newly scanned tracks never appear.
|
||||
|
||||
*Fix:* stamp each fetch with a request id (or the `selectedLibraryIdValue` + `changeGen` it started
|
||||
under) and discard the result if it no longer matches.
|
||||
|
||||
### C4 — `waitFor*` never resolves on a failed fetch, and leaks a subscriber forever
|
||||
|
||||
`frontend/src/store/library-store.ts:494-547` (4 copies), `frontend/src/store/playlist-store.ts:143-157`
|
||||
|
||||
`waitForTracks()` resolves only when `!tracksLoading && tracks !== null`. If the underlying binding
|
||||
rejects, `finally` sets `tracksLoading = false` but `tracks` stays `null`, so the promise **never
|
||||
settles** and its `subscribe()` callback is never removed from `LibraryStore.subscribers`. Every
|
||||
component or `explore-link` lookup awaiting that promise hangs, and each hung wait permanently adds
|
||||
a closure to the notify set that runs on every subsequent store change. `eagerFetch()`'s
|
||||
`void this.getTracks()` (`:474-477`) also swallows the rejection into an unhandled promise rejection.
|
||||
|
||||
*Fix:* have the fetch record an error state and reject/resolve all waiters in `finally`.
|
||||
|
||||
### C5 — Adding one track to one playlist re-downloads every track of every playlist
|
||||
|
||||
`frontend/src/store/playlist-store.ts:31-33` → `:124-129` → `:60`
|
||||
|
||||
`PlaylistTracksChanged` (emitted from 8 backend sites including `backend/playlist/favorites.go:200,231`)
|
||||
calls `invalidate()` → `GetAllPlaylistsWithTracks()`, which the backend implements as
|
||||
`GetAllPlaylists` + `GetAllPlaylistTracksWithMetadata` — **all rows of all playlists with full track
|
||||
metadata** (`backend/playlist/playlist.go:206-234`).
|
||||
|
||||
Toggling a single heart in the track list therefore refetches every playlist in the app. The store
|
||||
does this unconditionally (`void this.getPlaylists()` inside `invalidate()`), so it fires even when
|
||||
`playlist-view` — the only subscriber — has never been opened.
|
||||
|
||||
*Fix:* the event already carries the playlist id; refetch that one playlist, and only when there is
|
||||
a subscriber.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1 — One keystroke in the search box re-ranks every list in the app
|
||||
|
||||
`frontend/src/store/search-store.ts:55-57`, `frontend/src/store/controllers/search-controller.ts:29-32`
|
||||
|
||||
`SearchStore.notify()` is an unbatched broadcast to every subscriber, and `SearchController` maps it
|
||||
straight to `host.requestUpdate()`. Eight components hold a `SearchController`
|
||||
(`track-list`, `cover-grid`, `artists-view`, `genres-view`, `playlist-view`, `playlist-details`,
|
||||
`smart-playlist-details`, `search-bar`) and — because views stay mounted — **all of the mounted ones
|
||||
recompute on every keystroke**, not just the visible one:
|
||||
|
||||
- `track-list` → `rankTracks()` over 50 k tracks (`track-list.ts:271-289`)
|
||||
- `cover-grid` → filter + `[...albums].sort()` over 5 k albums (`cover-grid.ts:215-248`)
|
||||
- `artists-view`, `genres-view` → their own filter passes
|
||||
|
||||
Measured on Node/V8 (WebKit2GTK will be slower): `rankTracks`-equivalent work over 50 k tracks is
|
||||
**~18 ms**, so a single keystroke costs 50–100 ms of main-thread work across the mounted set even
|
||||
though four of the five results are invisible.
|
||||
|
||||
*Fix:* gate the notify on `searchStore.isSearchableView()` matching the subscriber's own view (the
|
||||
predicate already exists at `search-store.ts:41-43`), or have `SearchController` skip
|
||||
`requestUpdate()` when its host carries `view-hidden`.
|
||||
|
||||
### M2 — `rankTracks` allocates a `Set` and a closure per track, per keystroke
|
||||
|
||||
`frontend/src/components/track-list/search-ranking.ts:98-135`
|
||||
|
||||
`scoreTrack()` builds `new Set<string>()` plus a `check` closure for **every** track, then calls
|
||||
`col.accessor(track).toLowerCase()` (a fresh string allocation) per field. At 50 k tracks × 3 core
|
||||
fields that is 50 k Sets, 50 k closures and 150 k throwaway strings per keystroke. Benchmarked
|
||||
against a flat three-field comparison: **18.1 ms vs 5.8 ms** — a 3× tax purely from the dedup
|
||||
machinery, for a `seen` set that only ever contains 3–6 fixed ids.
|
||||
|
||||
*Fix:* hoist the deduped column list out of the per-track loop (compute it once in `rankTracks`) and
|
||||
drop the closure.
|
||||
|
||||
### M3 — Full-size original cover art rendered as a 24 px thumbnail in the track list
|
||||
|
||||
`frontend/src/components/track-list/columns.ts:53-63`
|
||||
|
||||
The `albumArt` column renders `track.CoverArtPath` — the **original embedded artwork**, commonly
|
||||
1500×1500 and several hundred KB — scaled to `width:24px;height:24px` by CSS. `CoverArtSmall`
|
||||
(100 px, quality 75) and `CoverArtMedium` (200 px) already exist on the same model
|
||||
(`wailsjs/go/models.ts:1583-1586`, generated by `backend/library/coverart.go:41-45`) and are used
|
||||
correctly everywhere else. There is also no `loading="lazy"` and no `decoding="async"`, so every row
|
||||
the virtualizer scrolls into view decodes a full-resolution JPEG synchronously on the main thread.
|
||||
|
||||
*Symptom:* enabling the Art column makes track-list scrolling stutter and inflates memory by the
|
||||
decoded bitmap of every album scrolled past.
|
||||
*Fix:* `track.CoverArtSmall || track.CoverArtPath`, plus `loading="lazy" decoding="async"`.
|
||||
|
||||
### M4 — Artist grid does a full linear scan of the album cache per card, per frame
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:988-1029`, called from `:1044` /
|
||||
`.renderItem` at `:1298`
|
||||
|
||||
When an artist has no `ImageSmall/Medium/Large` — the common case for a locally-tagged library —
|
||||
`renderArtistAvatar()` falls back to scanning **all of `libraryStore.cachedAlbums`** with
|
||||
`a.ArtistName.toLowerCase() === name` until it finds a match, allocating two lowercased strings per
|
||||
comparison. This runs inside the virtualizer's `renderItem`, i.e. for every visible card on every
|
||||
render pass. At 5 000 albums × ~50 visible cards that is 250 000 comparisons and 500 000 string
|
||||
allocations per scroll frame.
|
||||
|
||||
*Fix:* build a `Map<lowercasedArtistName, coverUrls>` once when `cachedAlbums` identity changes, and
|
||||
look up in O(1).
|
||||
|
||||
### M5 — Playlist and smart-playlist track lists are not virtualized
|
||||
|
||||
`frontend/src/components/playlist-details/playlist-details.ts:1265-1396`,
|
||||
`frontend/src/components/smart-playlist-details/smart-playlist-details.ts:1176-1250`
|
||||
|
||||
Both render **every** track with a plain `.map()` — no `lit-virtualizer`, no `repeat()` key. For a
|
||||
2 000-track playlist that is 2 000 rows × 8 elements in the DOM, and:
|
||||
|
||||
- `getVisibleTracks()` (`playlist-details.ts:750-780`) allocates a fresh `{track, trackIndex}`
|
||||
wrapper object for every track on **every** render, so the array identity always changes;
|
||||
- five event bindings per row (`@click`, `@dblclick`, `@contextmenu`, `@dragstart`, `@dragend`,
|
||||
`:1305-1330`) are new arrow functions each render, so lit removes and re-adds 10 000 listeners
|
||||
per pass;
|
||||
- both components hold a `PlayerController` (`playlist-details.ts` imports it), whose subscription
|
||||
is unfiltered — so **every** `PlaybackStateChanged` / `TrackChanged` / `VolumeChanged` /
|
||||
`MuteChanged` triggers that whole pass;
|
||||
- the row `<img>` (`:1386`, `smart-playlist-details.ts:1245`) has no `loading="lazy"`, so opening a
|
||||
2 000-track playlist fires 2 000 simultaneous cover-art requests at the Go asset handler.
|
||||
|
||||
Both files are ~30 kB of the bundle each and duplicate the same list; `track-list` already solves
|
||||
all of this (delegated handlers via `data-index`, stable `renderItem`, memoized caches) and is
|
||||
already reused by `genre-details.ts:276-278` via `.externalTracks`.
|
||||
|
||||
*Fix:* render these with `<track-list .externalTracks=…>` the way `genre-details` does, or at minimum
|
||||
add `lit-virtualizer` + delegated handlers.
|
||||
|
||||
### M6 — Visiting Settings costs a full re-render (and a console entry) every 3 seconds, forever
|
||||
|
||||
`frontend/src/components/config-page/config-page.ts:1016-1022`, `@state` at `:186`
|
||||
|
||||
The `IndexStatusChanged` handler assigns a freshly deserialized object to a `@state` field, so the
|
||||
identity always differs and Lit re-renders the entire 2 149-line `config-page` template every 3 s —
|
||||
for the rest of the session, since `config-page` is a cached primary view that never unmounts
|
||||
(`index.ts:71`) and its `disconnectedCallback` cleanup (`:1024-1036`, including
|
||||
`this.cancelIndexStatus?.()`) never runs.
|
||||
|
||||
The handler also does `console.log('IndexStatusChanged event received', status)` on every tick. With
|
||||
devtools open that retains ~1 200 status objects per hour as a genuine, unbounded leak.
|
||||
|
||||
*Fix:* drop the `console.log`; compare the incoming status field-wise and only assign on change.
|
||||
|
||||
### M7 — `explore-view` retains base64 image data forever
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:99-100`, `:987`, `:1003-1019`, `:936-944`
|
||||
|
||||
`thumbnailCache` stores the **data URL** returned by `GetThumbnails` —
|
||||
`"data:image/jpeg;base64," + base64(front-250 JPEG)` (`backend/explore/coverartproxy.go:114`,
|
||||
`backend/explore/coverart.go:27-29`). A 250 px CAA JPEG is ~15–25 kB, ~20–33 kB base64, and JS
|
||||
strings are UTF-16, so **~40–66 kB of retained heap per cached album**, plus the browser's decoded
|
||||
bitmap keyed off that same multi-kilobyte string.
|
||||
|
||||
Neither `thumbnailCache` nor `artistImageCache` is ever evicted, and `explore-view` is a cached
|
||||
primary view (`index.ts:67`) that never unmounts. A session of browsing — a desktop player runs for
|
||||
days — grows monotonically: a few hundred searches × ~50 results is on the order of hundreds of MB.
|
||||
|
||||
*Fix:* cap both maps with an LRU (a few hundred entries), or return a `/coverart/<mbid>` URL from the
|
||||
backend instead of a data URL so the browser's own image cache handles eviction.
|
||||
|
||||
### M8 — `exploreCache` is a second unbounded, never-evicted cache
|
||||
|
||||
`frontend/src/store/explore-cache.ts:35-38`
|
||||
|
||||
Four module-level `Map`s (`artists`, `albums`, `artistAlbums`, `artistTopTracks`) with `set` but no
|
||||
`delete`, no size cap and no TTL. `artistAlbums` holds full `MBReleaseGroup[]` discographies and
|
||||
`artistTopTracks` full `LBTopRecording[]` lists. Grows for the lifetime of the process.
|
||||
|
||||
*Fix:* bound each map (LRU, ~100 entries is plenty for "avoid a refetch when the user hits back").
|
||||
|
||||
### M9 — Every `<wa-icon>` is fetched from a remote CDN at runtime
|
||||
|
||||
`frontend/index.ts:29-30,47`; resolver in
|
||||
`@awesome.me/webawesome/dist/chunks/chunk.F5JLNOSF.js` (`library.default`)
|
||||
|
||||
WebAwesome's default icon library resolves to
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/<folder>/<name>.svg`. The literal is present in
|
||||
the built bundle. `setBasePath('/dist/webawesome')` does **not** change this — `getBasePath` is only
|
||||
consumed by the component autoloader (`chunk.2PWIIYRH.js:51`), and no
|
||||
`registerIconLibrary(...)` call exists anywhere in the app.
|
||||
|
||||
There are 165 `<wa-icon>` instances across 36 distinct names, so first paint of each view fires up to
|
||||
36 cross-origin requests. The icon module caches by URL, so it is bounded per session — but a
|
||||
desktop music player that is offline, on a captive network, or behind a firewall renders **no icons
|
||||
at all**, and cold start waits on fontawesome.com.
|
||||
|
||||
*Fix:* register a local icon library resolving to bundled SVGs (`src/assets/images/icons/` already
|
||||
holds a set), and add a `vite-plugin-static-copy` rule — the plugin is already a declared devDep
|
||||
(`package.json`) but is not referenced by `vite.config.mts`, and `dist/webawesome/` does not exist.
|
||||
|
||||
### M10 — 1.18 MB single chunk, no route-level code splitting
|
||||
|
||||
`frontend/vite.config.mts:16-22`, `frontend/index.ts:1-27`
|
||||
|
||||
Verified build (`vite build --outDir /tmp/yjbuild`):
|
||||
|
||||
```
|
||||
assets/main-BAFmIgXb.css 53.46 kB │ gzip: 7.48 kB
|
||||
assets/main-yB2fsiPY.js 1,183.64 kB │ gzip: 242.14 kB
|
||||
(!) Some chunks are larger than 500 kB after minification.
|
||||
```
|
||||
|
||||
`rollupOptions` sets only `input`; there is no `manualChunks` and no `import()` anywhere, and
|
||||
`index.ts` statically imports all 27 views, so every module is downloaded, parsed and
|
||||
**side-effect-evaluated** (every store singleton constructed, every `@customElement` registered)
|
||||
before first paint.
|
||||
|
||||
Sourcemap-attributed composition of the 1.16 MB of mapped output:
|
||||
|
||||
| bytes | source |
|
||||
|---|---|
|
||||
| 199 497 | `@awesome.me/webawesome` |
|
||||
| 76 008 | `components/autotag-view/autotag-view.ts` |
|
||||
| 52 828 | `components/explore-artist-details/…` |
|
||||
| 48 519 | `components/config-page/config-page.ts` |
|
||||
| 42 172 | `components/track-details/track-details.ts` |
|
||||
| 37 394 | `@lit-labs/virtualizer` |
|
||||
| 36 666 | `components/playlist-view/playlist-view.ts` |
|
||||
| 36 333 | `components/explore-album-details/…` |
|
||||
| 34 457 | `components/explore-view/explore-view.ts` |
|
||||
| 31 317 | `components/track-list/track-list.ts` |
|
||||
| 30 989 | `components/playlist-details/…` |
|
||||
| 30 661 | `components/cover-grid/cover-grid.ts` |
|
||||
| 30 180 | `wailsjs/go/models.ts` |
|
||||
|
||||
The startup-critical path is roughly `track-list` + `cover-grid` + `now-playing` + `audio-player` +
|
||||
`app-sidebar` + lit + virtualizer ≈ 200 kB. `autotag-view` (76 kB, the single largest app module),
|
||||
`config-page`, `explore-*`, `track-details`, `jobs-*` and `downloads-view` are all reachable only
|
||||
from a sidebar click.
|
||||
|
||||
*Fix:* replace the static imports in `index.ts` with `await import()` inside the `navigate` handler's
|
||||
`VIEW_TAGS` branch — the view is already created lazily there (`index.ts:120-127`), only the module
|
||||
is eager.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1 — `.renderItem` / `.keyFunction` are new closures every render in two virtualized views
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:1298-1299`,
|
||||
`frontend/src/components/genres-view/genres-view.ts:1196-1197`
|
||||
|
||||
`LitVirtualizer` declares both as `@property()` with the default `!==` `hasChanged`
|
||||
(`@lit-labs/virtualizer/LitVirtualizer.js:48-54`), so a fresh arrow function marks the property
|
||||
dirty and forces the virtualizer's own render pass on every host update. `cover-grid.ts:1893-1894`
|
||||
and `track-list.ts:1936-1937` correctly bind the stable `this.renderGridEntry` /
|
||||
`this.renderTrackRow` — these two do not. (`keyFunction` is a fresh closure in all four; `repeat()`
|
||||
keying limits the DOM damage to re-evaluated templates for the visible window.)
|
||||
|
||||
*Fix:* hoist to bound class fields, as `cover-grid` already does.
|
||||
|
||||
### m2 — Serial N+1 binding calls behind "play these"
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:945-971` — `GetAlbumsByArtist`, then
|
||||
`await GetAlbumTracks(album.ID)` **inside a `for` loop**. A 30-album artist is 31 sequential IPC
|
||||
round-trips.
|
||||
- `frontend/src/components/cover-grid/album-selection.ts:100-112` — same shape; Ctrl+A over 5 000
|
||||
albums is 5 000 sequential round-trips (partly mitigated by `albumFilePathCache`).
|
||||
- `frontend/src/components/genres-view/genres-view.ts:740-751` — one `GetTracksByGenre` per selected
|
||||
genre, all fired concurrently, each returning full track rows that are then deduped client-side.
|
||||
|
||||
*Fix:* add a single `GetTracksByAlbumIDs([]int64)` / `GetTracksByGenres([]string)` binding.
|
||||
|
||||
### m3 — Timers that survive because their view never unmounts
|
||||
|
||||
The cleanup is written correctly; it simply never executes for cached primary views.
|
||||
|
||||
- `frontend/src/components/downloads-view/downloads-view.ts:216-218` — a 30 s `setInterval` clock,
|
||||
cleared at `:226` in `disconnectedCallback`. Once Downloads is visited it ticks and re-renders the
|
||||
view for the rest of the session.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:481,503` — `onScrollCycleEnd` schedules
|
||||
`startScrollCycle` (2 s) which schedules the scroll (1.5 s), indefinitely, so a long track title
|
||||
drives a state change + re-render every ~3.5 s forever while it plays.
|
||||
|
||||
*Fix:* drive these off the `view-hidden` class (a `MutationObserver` on the host, or an explicit
|
||||
`viewActivated`/`viewDeactivated` hook in `index.ts`) rather than connect/disconnect.
|
||||
|
||||
### m4 — Permanent global `mousemove`/`mouseup` listeners for drag interactions
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1076-1077`,
|
||||
`frontend/src/components/now-playing/now-playing.ts:240-241`
|
||||
|
||||
Column resize and panel resize register document-level `mousemove` in `connectedCallback` and only
|
||||
remove it in `disconnectedCallback`. Both guard-and-return immediately
|
||||
(`track-list.ts:622-623`, `now-playing.ts:582-583`), so the cost is small, but they run on every
|
||||
pointer move anywhere in the app for the process lifetime and defeat the browser's ability to skip
|
||||
the listener entirely.
|
||||
|
||||
*Fix:* attach on `mousedown`, detach on `mouseup` — the standard drag pattern.
|
||||
|
||||
### m5 — `updated()` does unconditional DOM work every cycle
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:417-420` and
|
||||
`genres-view.ts:409-412` — `updateSizeProperties()` writes 2 `style.setProperty` calls on the host
|
||||
unconditionally (`artists-view.ts:671-701`), and `ensureWheelListener()` does a
|
||||
`shadowRoot.querySelector` every pass just to check a boolean it already stores
|
||||
(`:611-629`). Both should be guarded on the value/flag they already track.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:259-263` — `checkOverflows()` +
|
||||
`applyScrollDistances()` do 6 `querySelector`s and interleave `scrollWidth`/`clientWidth` reads
|
||||
with `style.setProperty` writes on every update, i.e. forced synchronous layout followed by
|
||||
invalidation, on a component that re-renders on every player-store change.
|
||||
|
||||
### m6 — O(total items) helpers on the selection hot path
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:160-173`
|
||||
|
||||
`getSelectedKeysOrdered()` walks the entire item list (50 k `getItemKey` calls) rather than the
|
||||
selection. It is called from every context-menu action, every favourite toggle and every
|
||||
`dragstart` (`track-list.ts:1379-1400`), so starting a drag of one row costs a 50 k-iteration loop.
|
||||
|
||||
Related: `frontend/src/components/track-list/track-list.ts:1507-1520` —
|
||||
`openBatchTrackDetails` does `filePaths.map(fp => this.tracks.find(...))`, i.e. O(selection × total).
|
||||
"Select all → Edit tags" on 50 k tracks is 2.5 × 10⁹ comparisons and will hang the renderer.
|
||||
|
||||
*Fix:* keep an index-ordered selection, and build a `Map<FilePath, Track>` for the batch lookup.
|
||||
|
||||
### m7 — The queue list stays live at zero width
|
||||
|
||||
`frontend/src/components/queue-panel/queue-panel.ts:214-231` (`:host { width: 0 }` when closed),
|
||||
`:653-681`
|
||||
|
||||
`contain: layout style paint` limits the blast radius, but the `lit-virtualizer` inside still has a
|
||||
real height and `min-width: 300px`, so it renders and measures its visible window on every queue
|
||||
change even with the panel closed — and `updated()` calls `scrollToIndex()` (`:675`) on every
|
||||
current-index change, which is `element(i).scrollIntoView()` on a laid-out but invisible element.
|
||||
|
||||
*Fix:* render `nothing` for the list body when the `open` attribute is absent.
|
||||
|
||||
### m8 — Backend emits scan progress nothing listens to
|
||||
|
||||
`frontend/src/events.ts:34-35`
|
||||
|
||||
`LibraryScanStarted` and `LibraryScanProgress` are declared but have **zero** consumers in
|
||||
`frontend/src/`. During a 50 k-file scan the backend serializes and pushes a progress payload across
|
||||
the IPC for an empty listener set.
|
||||
|
||||
*Fix:* either wire them into a scan indicator or stop emitting them.
|
||||
|
||||
### m9 — Remote artist avatars in Explore load eagerly
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:1461-1465`
|
||||
|
||||
The artist avatar `<img>` has neither `loading="lazy"` nor `decoding="async"`, unlike the album card
|
||||
20 lines below (`:1515-1519`) which has both. Every artist in a search result starts loading
|
||||
immediately.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1 — Dead dependency
|
||||
|
||||
`@lit-labs/signals` is declared in `frontend/package.json` but imported nowhere in `src/` or
|
||||
`index.ts`. Rollup tree-shakes it out of the bundle, so this is install-size only — but it also
|
||||
signals a state-management direction that was never taken, next to five hand-rolled
|
||||
`Set<Subscriber>` stores.
|
||||
|
||||
### p2 — Dead code carried in the bundle
|
||||
|
||||
`frontend/src/components/cover-grid/cover-grid.ts:1908-1962` — `renderSplitGrid()` is documented in
|
||||
its own comment as "Currently unreferenced (the single-grid path is the active rendering mode)",
|
||||
along with `getBeforeEntries`/`getAfterEntries`/`ensureSplitCache` and the `splitMode` branches that
|
||||
feed it. `cover-grid.ts` is 30.6 kB of the bundle.
|
||||
|
||||
### p3 — Store notify batching is inconsistent
|
||||
|
||||
`library-store`, `player-store`, `queue-store`, `job-store` and `download-store` all coalesce with
|
||||
`queueMicrotask` + a `notifyScheduled` flag. `search-store.ts:55-57` and `playlist-store.ts:133-135`
|
||||
do not. Lit batches the resulting `requestUpdate()`s anyway, so the impact is small, but the
|
||||
inconsistency is the kind that hides a real double-notify later.
|
||||
|
||||
### p4 — Empty library reads as "Loading tracks..." forever
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1900-1902` branches on `this.tracks.length === 0`
|
||||
rather than a loading flag, so a genuinely empty (or fully filtered-out) library shows a permanent
|
||||
loading message. `libraryCtrl.tracksLoading` already exists for this.
|
||||
|
||||
### p5 — `selectAll()` compares sizes, not membership
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:148` — `if (next.size === this._selectedItems.size) return;`
|
||||
short-circuits on cardinality alone. Same-size-different-membership is hard to reach today, but the
|
||||
guard is wrong as written; comparing against `this.host.getItemCount()` would express the intent.
|
||||
|
||||
### p6 — `ResizeObserver` on hidden views writes localStorage on every navigation
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1079-1085` → `onHostResize` (`:1218-1243`) →
|
||||
`normalizeWidths` + `saveColumnWidths` (`:515-534`). `.view-hidden` is
|
||||
`visibility: hidden; height: 0` (`frontend/index.css:162-170`), not `display: none`, so hidden views
|
||||
stay in the layout tree and their `ResizeObserver`s fire on every navigation. Cheap (localStorage
|
||||
only), but it is work done for an invisible element.
|
||||
|
||||
---
|
||||
|
||||
## What is already right
|
||||
|
||||
Worth stating plainly, because it is most of the codebase and the findings above are the exceptions:
|
||||
|
||||
- **`track-list` is a well-built virtualized list.** Memoized filter/sort caches keyed on input
|
||||
identity (`:238-270`), delegated event handlers via `data-index` with zero per-row closures
|
||||
(`:1140-1157`, `:1290-1312`), a stable `renderItem`, an `_itemSize` hint that avoids
|
||||
lit-virtualizer's scroll-error correction (`:222-228`), RAF-throttled scroll persistence
|
||||
(`:1280-1291`), and an inline `<svg>` for the per-row favourite icon instead of a `<wa-icon>` that
|
||||
would fetch. All 50 k rows go through this path.
|
||||
- **`cover-grid` memoizes correctly** — `buildGridEntries()` is keyed on the filtered-albums array
|
||||
identity (`:906-926`), so the virtualizer's `items` reference is stable across re-renders, and its
|
||||
covers pick the right thumbnail tier with `loading="lazy" decoding="async"` and explicit
|
||||
`width`/`height` (`:1803-1814`).
|
||||
- **`queue-store` is delta-driven**, not snapshot-driven (`queue-store.ts:82-110`) — index, mode and
|
||||
track-list mutations each ride their own event.
|
||||
- **`job-store` is the model for a push store**: microtask-coalesced notify with a documented
|
||||
rationale, and it evicts cached logs for jobs the backend has forgotten
|
||||
(`job-store.ts:229-236, 263-276`).
|
||||
- **`favorites-store` is Set-keyed**, so `isFavorited` in a row render is O(1) (`:99-101`).
|
||||
- **`LibraryController`'s `changeGeneration` guard** correctly suppresses `requestUpdate()` when only
|
||||
a loading flag toggled (`library-controller.ts:33-47`) — exactly the granularity most of the other
|
||||
controllers lack.
|
||||
- **`genre-details` and `artist-details` reuse `track-list` / `cover-grid`** via `.externalTracks` /
|
||||
`.externalAlbums` instead of reimplementing a list — which is precisely the fix M5 asks for.
|
||||
- **Detail views are ephemeral** (`index.ts:143-147`), so their `disconnectedCallback` cleanup does
|
||||
run and their per-instance caches (e.g. `explore-artist-details`' three `Map`s) are collectable.
|
||||
The leaks in M7/M8/m3 are all on the *cached* primary views.
|
||||
|
||||
## Things I checked and found no problem with
|
||||
|
||||
Recorded so they are not re-audited:
|
||||
|
||||
- **`localeCompare` in sort comparators** (`track-list/columns.ts:15`,
|
||||
`cover-grid/cover-grid-types.ts:50-76`). Benchmarked 50 k-element sorts: bare `localeCompare`
|
||||
**16.5 ms** vs a hoisted `Intl.Collator.compare` **28.7 ms**. V8 already caches the default
|
||||
collator; hoisting one would be a pessimization. No finding.
|
||||
- **Repeated `addEventListener('visibilityChanged', this.onVisibilityChanged)` in
|
||||
`track-list.loadTracks()`** (`:1249-1254`). The handler is a stable class-field arrow, so repeat
|
||||
registration with the same type+function is a spec-level no-op. Not a leak.
|
||||
- **WebAwesome's autoloader `MutationObserver`.** `startLoader()` is exported from
|
||||
`webawesome.js` but never called by the app, so no global mutation observer is installed. (The
|
||||
icon CDN issue in M9 is a separate mechanism.)
|
||||
- **`layout shift` from row cover art.** Every list container has a fixed pixel box
|
||||
(`playlist-details.ts:984-998`, `columns.ts:61`, `cover-grid.ts:1809-1810`), so images do not
|
||||
reflow their rows.
|
||||
- **`job-store` / `download-store` growth.** Both bound their state to the backend snapshot and
|
||||
evict.
|
||||
@@ -26,7 +26,11 @@ make dev-headless # Start headless in the background and return (SEED=<name>
|
||||
make dev-stop # Stop it (SIGTERM, so shutdown hooks run)
|
||||
make dev-logs # Tail .dev/app.log
|
||||
make testdata # Generate the deterministic fixture music library
|
||||
make bulkdata # Generate the ~50k-track measurement library (BULK_TRACKS=)
|
||||
make sandbox-seed NAME=<n> # Build a seeded YJ_HOME by *running* the app
|
||||
make sandbox-seed-bulk # Same, from the bulk library (minutes; it is a real scan)
|
||||
make perf LABEL=<n> # Measure a running app; writes .dev/perf/<n>.json
|
||||
make perf-compare BEFORE=<a> AFTER=<b> # Print the before/after table
|
||||
make build-dev # Debug build with symbols
|
||||
make build-prod # Production build (stripped, UPX-compressed)
|
||||
make generate # Run code generators (sqlc + templ via go generate)
|
||||
@@ -111,7 +115,21 @@ Frozen regression specs live in `e2e/` (its own npm package, so the
|
||||
Vitest browser mode does not share a package with the Playwright
|
||||
runner): `make e2e` against an already-running app.
|
||||
|
||||
**The cheapest tier needs none of that.** `make ui-test` runs 313
|
||||
**A fifth tier exists for questions whose answer is a number**, not a
|
||||
pass: `make bulkdata` generates a ~50 000-track library (11 s, 466 MB,
|
||||
gitignored), `make sandbox-seed-bulk` seeds from it by running the app
|
||||
like any other seed, and `make perf LABEL=x` measures startup, the
|
||||
bundle's shape and each view's first open, keystroke cost, what a
|
||||
finished track provokes, what one favourite toggle costs, what sitting
|
||||
idle on Settings costs, and heap after a scripted browse. A measurement
|
||||
that needs state the seed does not have stages it itself, idempotently,
|
||||
so a before and an after see the same shape — the favourite number is
|
||||
meaningless against the seed's one empty playlist, so it builds ten
|
||||
500-track ones first.
|
||||
It wraps every bound Go method, so "did that refetch the library" is a
|
||||
fact rather than an inference. It is not a spec and does not run in CI.
|
||||
|
||||
**The cheapest tier needs none of that.** `make ui-test` runs 480
|
||||
Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no
|
||||
seeded library and no virtual display, because `frontend/wailsjs/` is a
|
||||
pure passthrough to `window.go` / `window.runtime` and
|
||||
@@ -179,6 +197,20 @@ See `.planning/plans/active/005-agent-development-harness.md`.
|
||||
same drift is possible again if a schema change ships without
|
||||
updating both files. Don't reintroduce a *second* description of
|
||||
the schema anywhere else.
|
||||
- **A write wearing a query's shape still needs the writer.**
|
||||
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
|
||||
*query-only* read pool (a second `sql.DB` over the same file), so
|
||||
an `INSERT ... RETURNING` issued through one fails at runtime with
|
||||
"attempt to write a readonly database (8)" — which is exactly what
|
||||
`CreateSmartPlaylist` did, meaning no smart playlist could be
|
||||
created at all. Use `ExecContext`, or `QueryRowWriter` when the
|
||||
statement really does return a row. Nothing caught this because
|
||||
`NewTestDB` shares one in-memory connection and leaves `readDB`
|
||||
nil, so `reader()` returns the *writer* under test and the unit
|
||||
tests exercised a handle the app does not have.
|
||||
`TestNoWritesOnTheReadPool` walks the tree for it, in the same
|
||||
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
|
||||
lint pass only sees one build configuration.
|
||||
- **Squashing is fine pre-1.0.** While this hasn't shipped to real
|
||||
users, periodically folding `sql/migrations/` into `sql/schemas/`
|
||||
and deleting the migration files (then wiping your own dev/sandbox
|
||||
@@ -187,6 +219,12 @@ See `.planning/plans/active/005-agent-development-harness.md`.
|
||||
it" workflow, opt-in instead of mandatory. Stop doing that once
|
||||
real user databases exist in the wild.
|
||||
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
|
||||
- `jobs` — The registry every long-running operation reports through:
|
||||
progress, pause/cancel, a global indicator and (for scans) a pause
|
||||
that survives a restart. Library scans, index builds, downloads and
|
||||
the autotag apply are registered; anything that is not registered has
|
||||
none of that, which is exactly how the three gaps the audit found
|
||||
came about.
|
||||
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
|
||||
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
|
||||
- `mediacontrols` — MPRIS integration on Linux via D-Bus.
|
||||
@@ -224,6 +262,101 @@ work happens **once, centrally**, and users download the result:
|
||||
|
||||
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
|
||||
|
||||
**A view is a chunk, and three components are not.** `index.ts` holds a
|
||||
loader table (`VIEW_LOADERS`, `DETAIL_LOADERS`) and `await`s a view's
|
||||
module before creating its element — `document.createElement` on an
|
||||
undefined tag yields an inert `HTMLElement` rather than throwing, so a
|
||||
missing entry is a blank page, not an error. Navigations are numbered
|
||||
and anything after the `await` re-checks it is still the newest, or a
|
||||
slow chunk lands on top of a faster navigation. Every chunk is then
|
||||
warmed on idle, so the split is paid once at startup rather than on
|
||||
every first visit. **`notification-host`, `inline-notice` and
|
||||
`confirm-dialog` stay eager on purpose**: a failure surface that has to
|
||||
fetch a chunk before it can speak is not a failure surface, and the
|
||||
moment it is most needed is the likeliest moment loading one fails.
|
||||
`first-run-wizard` and the startup chrome are eager for the ordinary
|
||||
reason — they are the first paint.
|
||||
|
||||
**A primary view is cached, not unmounted.** `index.ts` keeps every
|
||||
primary view in the DOM and toggles a `.view-hidden` class, because that
|
||||
is what preserves `scrollTop` across navigation — so
|
||||
`disconnectedCallback` never fires for one, and anything registered
|
||||
there runs for the life of the session from pages it is not on. The
|
||||
missing half is `utils/view-lifecycle.ts`: navigation calls
|
||||
`viewDeactivated()` on the outgoing view and `viewActivated()` on the
|
||||
incoming one, and a view registers its document listeners, timers and
|
||||
backend subscriptions through `listenWhileActive` /
|
||||
`intervalWhileActive` / `whileActive`, which are torn down on the way
|
||||
out. An off-screen view also does not render. A shared reactive
|
||||
controller gets the same treatment via `registerViewAware`.
|
||||
|
||||
**The player's position comes from the player.** `seek-bar` renders
|
||||
`PlaybackPositionChanged` (payload `player.PositionInfo`), emitted at
|
||||
1 Hz while playing and immediately on load, play, pause, seek and
|
||||
natural finish. Its local `setInterval` is interpolation *between*
|
||||
reports only, stopped and restarted by every one of them — it used to
|
||||
be the clock, and counted itself 30 s away from the backend across four
|
||||
keyboard seeks. A report carries `trackChangeId` (the store is a
|
||||
singleton, so a bar mounting later must not adopt a report about the
|
||||
previous track) and a `seq` (the same second reported twice still has
|
||||
to reset the interpolation).
|
||||
|
||||
What the player cannot do, it says: `PlaybackFailed` is emitted from
|
||||
both the load and the play path, auto-advance **skips** the failed
|
||||
track (bounded by the queue length, so a disconnected drive stops after
|
||||
one pass), and the bottom bar shows one coalescing line — "Skipped 12
|
||||
tracks that could not be played." That line is the Inline level of the
|
||||
app's one notification surface, below.
|
||||
|
||||
**Failure has one voice, and the caller picks how loud.**
|
||||
`store/notification-store.ts` is the only notification surface; before
|
||||
it, 84 `catch` blocks ended at `console.error` and two components had
|
||||
grown private toasts. Four levels, chosen by the call site from one
|
||||
rule — *a failure is only worth interrupting for if the user can do
|
||||
something about it that they are not already doing*:
|
||||
|
||||
- **Blocking** (`wa-dialog`, must be acknowledged) for data at risk: a
|
||||
folder left holding a mix of old and new tags. Two callers are
|
||||
anticipated; a third should be argued for.
|
||||
- **Persistent** (stays, with an action) for something the user asked
|
||||
for that did not happen and retrying is meaningful.
|
||||
- **Transient** (a toast) for a small action whose state visibly
|
||||
reverted anyway — a favourite that came back.
|
||||
- **Inline**, rendered by `<inline-notice region="…">` in the panel
|
||||
that failed, never as a toast.
|
||||
|
||||
Three things about it are load-bearing. **Coalescing lives in the
|
||||
store**, keyed by `(level, region, key)` within a window, so 200
|
||||
unplayable files are one message with a count and no future caller has
|
||||
to remember that. **An inline notification carries a region**, because
|
||||
"inline" says *not global*, not *where*. And **the bottom band belongs
|
||||
to the player** — the app-level stack sits under the header, since the
|
||||
player's own floating notice grows upward by however many lines it
|
||||
needs and a bottom-anchored stack collides with it on a small window.
|
||||
|
||||
What reaches a person is a sentence: `utils/describe-error.ts` maps the
|
||||
causes a user can act on (offline, timeout, not found, permission,
|
||||
database busy) to copy, `explainError` repeats a backend message when it
|
||||
is one of *our* sentinels rather than a Go wrapping chain, and the raw
|
||||
text stays in `console.error`. The one documented exception is a
|
||||
download client's connection test, whose verbatim error is the user's
|
||||
debugging tool.
|
||||
|
||||
Destructive actions ask once, through `confirmAction()`
|
||||
(`components/confirm-dialog/`), which is a `wa-dialog` and so brings the
|
||||
focus trap and Escape the hand-rolled overlays do not have.
|
||||
|
||||
**One keyboard authority.** No component owns a document keydown
|
||||
listener for its own shortcuts; it registers *panel-scoped* bindings
|
||||
(`autotag.*`, `tracklist.*` in `backend/shortcuts/config.go`) and
|
||||
claims a scope by setting `shortcutScope` on the mixin, which publishes
|
||||
`data-shortcut-scope` and claims it as the ambient scope
|
||||
(`services/shortcut-scope.ts`) while it is on screen. The shortcut
|
||||
service resolves focus → panel → global, and yields a key to a focused
|
||||
control that owns it (button/select/slider/checkbox, or anything inside
|
||||
an open dialog) so the unmodified single-key global bindings do not
|
||||
steal Space and the arrows.
|
||||
|
||||
Two cross-cutting pieces of that UI are worth knowing before touching
|
||||
a list or a detail view:
|
||||
|
||||
@@ -244,8 +377,192 @@ a list or a detail view:
|
||||
since "something is renderable" and "this is the catalog's answer"
|
||||
are different questions.
|
||||
|
||||
**Icons are bundled, and the app works offline.** `src/icons/`
|
||||
overrides Web Awesome's `default` icon library, whose resolver fetches
|
||||
every `<wa-icon>` from `ka-f.fontawesome.com` at runtime — so the app
|
||||
had no icons at all offline, and `setBasePath()` does not affect it
|
||||
(only the component autoloader reads that). Overriding the library
|
||||
fixes all 165 call sites without changing one of them. Three things
|
||||
about it are load-bearing. The set is **Font Awesome Free** (CC BY 4.0,
|
||||
vendored with its licence by `frontend/scripts/fetch-icons.mjs`)
|
||||
because the kit CDN serves **Pro**, which cannot be redistributed. The
|
||||
names are a committed list (`src/icons/names.txt`) rather than anything
|
||||
derived, because twenty call sites compute their icon name from state
|
||||
and no static pass can enumerate them. And a name that is not bundled
|
||||
is therefore **reported at runtime** to `window.__yjIconMisses` and
|
||||
drawn as a fallback — an e2e sweep asserts there are none — since a
|
||||
missing icon used to be impossible, the CDN having had everything.
|
||||
|
||||
**Ask for what the caller uses, once.** "Play this artist" resolved
|
||||
file paths with one `GetAlbumTracks` per album, sequentially, and every
|
||||
one of the four sites doing that asked for whole track rows to read
|
||||
`FilePath` off them — 5 genres cost 6 MB over the IPC (`perf.m2`).
|
||||
`GetFilePathsByAlbums(ids, libraryID)` and `GetFilePathsByGenres(names,
|
||||
libraryID)` answer in one query and carry only the paths. They return
|
||||
the paths **grouped by album id / genre name**, because the caller owns
|
||||
the order — an album list is sorted by name, not by id, and a flattened
|
||||
result would silently reorder a queue — and because `cover-grid`'s drag
|
||||
cache stores them per album. A `libraryID` of 0 means "every library",
|
||||
matching an unset library filter.
|
||||
|
||||
**Event-driven communication**: Backend emits events via Wails runtime; frontend stores subscribe to them. Event names are constants in `backend/events/`.
|
||||
|
||||
`frontend/src/events.ts` is **generated** from `backend/events/events.go`
|
||||
by `backend/events/cmd/genevents` — never edit it. It renders a const
|
||||
block's doc comment as TypeScript line comments, every line of it: it
|
||||
used to prefix only the first, so a comment that ran to a second
|
||||
paragraph emitted bare prose into the object literal and `make generate`
|
||||
— a pre-commit hook — produced a file that does not parse.
|
||||
|
||||
**An event's cost is part of its meaning.** `TrackMetadataChanged`
|
||||
means *the tags on disk were rewritten*, which can change an album, an
|
||||
artist or a genre — so `library-store` answers it by discarding every
|
||||
cached collection and refetching. That makes it the most expensive
|
||||
event in the app, and it must not be reused for something cheap:
|
||||
finishing a track used to emit it, which cost ~37 MB across the IPC and
|
||||
~0.8 s of blocked main thread *per song* at 50 000 tracks, and cleared
|
||||
the user's track selection while it did. `TrackPlayCountChanged`
|
||||
carries `{audioFileId, filePath, playCount, lastPlayed}` — deliberately
|
||||
everything needed to patch one track in place, so no consumer has any
|
||||
reason to invalidate anything. The store replaces the tracks array
|
||||
(consumers key memoized filter/sort caches on its identity) while
|
||||
sharing every unchanged Track, and `track-list` **retains** its
|
||||
selection across a refetch rather than clearing it, since the keys are
|
||||
file paths and those survive one.
|
||||
|
||||
The same rule reaches the other way: **an event carries what a consumer
|
||||
needs so it never has to invalidate.** `PlaylistTracksChanged` carries
|
||||
the playlist id, and `playlist-store` refetches *that* playlist
|
||||
(`GetPlaylistTracks`, plus `GetAllPlaylists` for the summaries, since
|
||||
`UpdatedAt` is a sort key) rather than `GetAllPlaylistsWithTracks`,
|
||||
which is every row of every playlist — 2.6 MB and 172 ms for one heart
|
||||
before the fix. It falls back to a full invalidate only where a patch
|
||||
cannot be shown to be equivalent: no id (the bulk paths emit one), a
|
||||
cold cache, an unknown id, or a fetch already in flight. And **a store
|
||||
with no subscriber fetches nothing**: `playlist-view` is the only
|
||||
reader and is created lazily, so neither the invalidation nor — more
|
||||
expensively — the singleton's own construction warms a cache for a page
|
||||
that may never open.
|
||||
|
||||
**An unchanged payload is not an event.** `explore`'s index status used
|
||||
to be pushed on a 3 s ticker for the life of the process, byte-identical
|
||||
once the index was ready, and `config-page` assigns it to a `@state`
|
||||
field — so a user who had once opened Settings paid a full re-render of
|
||||
a 2 000-line template every 3 s, forever, for no news. `emitStatus` now
|
||||
drops a status equal to the last one it sent, which is the rule stated
|
||||
once instead of at twenty call sites. The corollary is load-bearing:
|
||||
**every mutation of something the status derives must call `emitStatus`
|
||||
itself**, because there is no longer a poll to notice it. `si.ready`
|
||||
and `si.cancel` are both derived, and both were relying on the ticker.
|
||||
|
||||
**A cache on a cached view needs a ceiling, and so does everything
|
||||
else holding what it holds.** `explore-view` never unmounts, and its
|
||||
two art caches were plain `Map`s: twenty-four searches retained
|
||||
20.58 MB and were still accelerating, because a cover thumbnail is a
|
||||
~27 kB base64 data URL and an artist photo is a ~128 kB one. They are
|
||||
`LRUMap`s now (`utils/lru-map.ts` — a `Map` re-inserted on read and
|
||||
trimmed from the front), capped from the measured size of an entry and
|
||||
kept several times larger than a screenful, since a cap below the
|
||||
visible count evicts art that is still rendered and the re-render
|
||||
fetches it straight back.
|
||||
|
||||
Two things about it are load-bearing. **A cap is only a bound if it
|
||||
covers every reference**: the artist photo's data URL is held by both
|
||||
`artistImageCache` and `exploreCache.artists`, so capping either alone
|
||||
frees nothing at all and reads as a fix that did not work —
|
||||
`ARTIST_IMAGE_CACHE_LIMIT` is exported and shared for that reason. And
|
||||
**a bound has to stay checkable**: caches register with
|
||||
`utils/cache-stats.ts`, so `window.__yjCacheStats()` reports entries,
|
||||
retained chars and cap in one eval, rather than the next session having
|
||||
to rebuild the twenty-four-search reproduction before it can tell
|
||||
whether the ceiling still holds.
|
||||
|
||||
**A list pays per row, and only while scrolling.** The track list's Art
|
||||
column rendered `CoverArtPath` — the original artwork — into a 24 px
|
||||
box while `CoverArtSmall` sat unused on the same model, and
|
||||
`artists-view` linear-scanned every cached album per card per frame to
|
||||
find an avatar fallback. Both are invisible to every test tier: nothing
|
||||
renders differently and nothing fails, the app is just slower to
|
||||
scroll. Pick the tier for the box you are drawing (`cover-grid`'s
|
||||
`getCoverUrl()` is the model), always `loading="lazy" decoding="async"`
|
||||
on a row image, and build a lookup keyed on the store array's identity
|
||||
rather than searching it — the store replaces that array when its
|
||||
contents change and shares the unchanged members, which is the same
|
||||
signal `track-list`'s memoized caches key on.
|
||||
|
||||
**The same rule, on the selection path, was the worst stall in the
|
||||
app.** Five components turned selected file paths back into tracks with
|
||||
`filePaths.map(fp => tracks.find(…))`, so "Select all → Edit tags" at
|
||||
50 000 tracks blocked the main thread for **three to six seconds**
|
||||
(`perf.m6`). `utils/track-index.ts` is that lookup written once: a
|
||||
`WeakMap` from the array's identity to a `Map<FilePath, Track>`, safe
|
||||
for exactly the reason above and collected for free when the store
|
||||
drops the array. **68 ms after.**
|
||||
|
||||
Its neighbour is a deliberate non-fix. `getSelectedKeysOrdered()` walks
|
||||
the *list* rather than the selection, which the same finding calls out —
|
||||
measured at **3 ms** for 50 000 items, so it stays a walk, with an early
|
||||
exit once everything is found (which helps a selection near the top of
|
||||
the list and, honestly, almost nothing at the bottom). The obvious fix —
|
||||
keep each key's index beside it — is the one thing that cannot be done
|
||||
here: an index goes stale on any re-sort, re-filter or refetch while a
|
||||
file path survives all three, which is precisely why `retain()` drops
|
||||
`lastSelectedIndex` and keeps the keys. Three milliseconds does not buy
|
||||
a silently mis-ordered queue insert.
|
||||
|
||||
**Work in `updated()` runs on every pass, so it has to say what it
|
||||
depends on.** `now-playing` measured and rewrote its text geometry
|
||||
every update — six `querySelector`s and a read/write interleave — and
|
||||
`player-store` notifies while playing, so it did that several times a
|
||||
second about a component whose DOM had not changed (`perf.m5`). It now
|
||||
runs only when its geometry key changes: the rendered title, the
|
||||
rendered artist, **the two scroll flags**, or the ResizeObserver
|
||||
reporting the panel resized. The scroll flags are in that list because
|
||||
`.will-scroll .scroll-content` carries `padding-right: 2em`, so
|
||||
applying the class changes the distance the marquee travels (−128 px
|
||||
before it, −158 px after) — a guard on the text alone leaves every
|
||||
first hover scrolling short, and nothing would fail. Reads before
|
||||
writes in one place, so the interleave cannot come back.
|
||||
|
||||
Two corollaries. **Cost is measured where the work runs, not where it
|
||||
is written**: those same reads cost 3 µs when the layout is clean and
|
||||
0.103 ms when it is dirty, so the finding's magnitude only appears if
|
||||
the DOM actually changed. And **a drag's document listeners belong to
|
||||
the drag** — `track-list` and `now-playing` attach `mousemove`/`mouseup`
|
||||
on `mousedown` and drop them on `mouseup` (and on disconnect, for a
|
||||
drag interrupted by the component going away).
|
||||
|
||||
**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.** `<lit-virtualizer>`'s rows are produced by the `virtualize`
|
||||
directive, which runs when one of the *virtualizer's own* properties
|
||||
changes — not when its parent re-renders. So a list with memoized
|
||||
`items` and a stable `renderItem` never repaints on host state, and
|
||||
selection silently stops highlighting while the controller holds
|
||||
exactly the right keys. Both playlist detail views virtualize now
|
||||
(`perf.M5`: 22 090 elements and 2 000 eager cover requests for a
|
||||
2 000-track playlist, against 487 and 0), and both therefore push
|
||||
`virtualizer.requestUpdate()` on a selection change and on a
|
||||
playing-track change, which is what `track-list` has always done.
|
||||
|
||||
The corollary is that **`artists-view` and `genres-view`'s per-render
|
||||
arrow functions are load-bearing.** `perf.m1` asks for them to be
|
||||
hoisted to stable fields the way `cover-grid` does; hoisting them is
|
||||
what *stops* the virtualizer seeing a changed property, so the cards
|
||||
keep whatever classes they had — measured at 1 highlighted card before
|
||||
and 0 after, with no compensating win to pay for it.
|
||||
`frontend/test/components/card-grid-repaint.test.ts` fails on that
|
||||
change and exists for no other reason.
|
||||
|
||||
Two smaller rules from the same pass. A row inside a virtualizer needs
|
||||
`width: 100%`, because the virtualizer positions its children
|
||||
absolutely and a grid row otherwise shrinks to fit its content and
|
||||
stops lining up with the header above it. And a panel that is closed
|
||||
renders no list at all (`perf.m7`): `width: 0` and `contain` bound the
|
||||
damage but do not stop a virtualizer inside from measuring its window
|
||||
on every change, or `scrollToIndex` from calling `scrollIntoView()` on
|
||||
something invisible.
|
||||
|
||||
Emit through **`events.Emit(ctx, name, data...)`**, never
|
||||
`runtime.EventsEmit` — wails `log.Fatalf`s (unrecoverably) on any
|
||||
context that does not carry its runtime, which includes every
|
||||
|
||||
@@ -37,6 +37,15 @@ dev-logs: ## Tail the headless app log
|
||||
sandbox-seed: testdata ## Build a seeded YJ_HOME snapshot: make sandbox-seed NAME=<n>
|
||||
@./scripts/seed-sandbox.sh $(if $(NAME),--name $(NAME),)
|
||||
|
||||
# The bulk seed is the *measurement* seed, not a fixture seed. Same
|
||||
# script and the same discipline (the app builds it by scanning for
|
||||
# real); the only difference is which manifest it is pointed at. It is
|
||||
# a separate target because a 50 000-track scan is minutes, and nothing
|
||||
# routine should depend on it.
|
||||
sandbox-seed-bulk: bulkdata ## Build a seeded YJ_HOME from the bulk library
|
||||
@./scripts/seed-sandbox.sh --name $(if $(NAME),$(NAME),bulk) \
|
||||
--manifest .dev/music_library_bulk.manifest.json
|
||||
|
||||
sandbox-seeds: ## List built seeds
|
||||
@ls -1 .dev/seeds/*.tar 2>/dev/null | sed 's|.*/||; s|\.tar$$||' \
|
||||
|| echo " (none; build one with: make sandbox-seed NAME=default)"
|
||||
@@ -45,6 +54,21 @@ sandbox-seeds: ## List built seeds
|
||||
# daemonises, which is the opposite of what Playwright's `webServer`
|
||||
# supervises, and starting one per run would rebuild the frontend every
|
||||
# time. globalSetup fails with the exact commands to run if it is down.
|
||||
# Phase 4 of plan 007 is verified by measurement rather than assertion,
|
||||
# so this is not a spec and does not run in CI: it produces a number to
|
||||
# read, against a running app seeded with the bulk library.
|
||||
#
|
||||
# make sandbox-seed-bulk && make dev-headless SEED=bulk
|
||||
# make perf LABEL=before ... change something ... make perf LABEL=after
|
||||
# make perf-compare BEFORE=before AFTER=after
|
||||
perf: ## Take a performance measurement (LABEL=<name>) of a running app
|
||||
@cd e2e && pnpm install --silent && \
|
||||
node perf/measure.mjs --label $(if $(LABEL),$(LABEL),current)
|
||||
|
||||
perf-compare: ## Print a before/after table: BEFORE=<a> AFTER=<b>
|
||||
@cd e2e && node perf/measure.mjs --compare \
|
||||
$(if $(BEFORE),$(BEFORE),before) $(if $(AFTER),$(AFTER),after)
|
||||
|
||||
e2e: ## Run the Playwright smoke suite against a running dev-headless app
|
||||
@cd e2e && pnpm install --silent && npx playwright test $(E2E_ARGS)
|
||||
|
||||
@@ -96,7 +120,8 @@ bindings: ## Regenerate frontend/wailsjs from the bound Go structs
|
||||
frontend/wailsjs/runtime/package.json
|
||||
|
||||
.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \
|
||||
sandbox-seed sandbox-seeds e2e e2e-setup e2e-report \
|
||||
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
||||
perf perf-compare \
|
||||
ui-test ui-watch ui-visual ui-visual-update ui-setup \
|
||||
bindings bindings-check skill-check
|
||||
|
||||
@@ -220,7 +245,19 @@ testdata-clean: ## Delete the generated fixture library
|
||||
rm -rf test_data/music_library_test test_data/music_library_broken \
|
||||
test_data/music_library_test.manifest.json
|
||||
|
||||
.PHONY: testdata testdata-force testdata-clean
|
||||
# The bulk library answers a different question from the fixture one:
|
||||
# not "does this behave correctly" but "how does this behave at the
|
||||
# size the audit measured". ~11 s, ~470 MB, into a gitignored .dev/,
|
||||
# and deliberately not a dependency of `make test`.
|
||||
BULK_TRACKS ?= 50000
|
||||
|
||||
bulkdata: ## Generate the bulk measurement library (BULK_TRACKS=50000)
|
||||
go run ./cmd/gentestdata -bulk $(BULK_TRACKS)
|
||||
|
||||
bulkdata-clean: ## Delete the bulk measurement library
|
||||
rm -rf .dev/music_library_bulk .dev/music_library_bulk.manifest.json
|
||||
|
||||
.PHONY: testdata testdata-force testdata-clean bulkdata bulkdata-clean
|
||||
|
||||
# The tag sets must match `make test` exactly, or lint is checking three
|
||||
# configurations that nothing builds. webkit2_41 is not optional: without
|
||||
|
||||
@@ -189,6 +189,7 @@ func NewYellowJacketApp(
|
||||
yjApp.explore,
|
||||
yjApp.tagWriter,
|
||||
)
|
||||
yjApp.autotag.SetJobRegistry(yjApp.jobs)
|
||||
|
||||
// Create the download subsystem. Acquiring music is optional: a
|
||||
// failure here (unwritable data dir, say) must not stop the app
|
||||
@@ -494,8 +495,19 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.player.SetMediaControls(yj.mediaControls)
|
||||
}
|
||||
|
||||
// OnBeforeClose captures window state while the window is still alive.
|
||||
// OnBeforeClose captures window state while the window is still alive,
|
||||
// and asks first when quitting would abandon a job that is writing to
|
||||
// the user's files.
|
||||
//
|
||||
// Returning true keeps the window open. Quitting mid-apply cancels the
|
||||
// service context and leaves a folder half-retagged with nothing
|
||||
// recording where it stopped (errors.p4), which is the one case worth
|
||||
// interrupting a quit for.
|
||||
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
if yj.confirmQuitDuringWrites(ctx) {
|
||||
return true
|
||||
}
|
||||
|
||||
w, h := wailsruntime.WindowGetSize(ctx)
|
||||
|
||||
// Guard against a bogus size clobbering a good saved one. During
|
||||
@@ -533,6 +545,33 @@ func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// confirmQuitDuringWrites returns true when the user chose to stay.
|
||||
// A dialog that cannot be shown is not allowed to trap anyone in the
|
||||
// app, so any error here quits.
|
||||
func (yj *YellowJacketApp) confirmQuitDuringWrites(ctx context.Context) bool {
|
||||
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
||||
return false
|
||||
}
|
||||
|
||||
answer, err := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
|
||||
Type: wailsruntime.QuestionDialog,
|
||||
Title: "Tags are still being written",
|
||||
Message: "YellowJacket is rewriting tags on your files. " +
|
||||
"Quitting now leaves that folder holding a mix of old and " +
|
||||
"new tags.\n\nQuit anyway?",
|
||||
Buttons: []string{"Quit anyway", "Keep writing"},
|
||||
DefaultButton: "Keep writing",
|
||||
CancelButton: "Keep writing",
|
||||
})
|
||||
if err != nil {
|
||||
yj.logger.Warn("could not ask about quitting mid-write", "err", err)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return answer == "Keep writing" || answer == "No"
|
||||
}
|
||||
|
||||
// OnShutdown saves player state and cleans up resources before the application exits.
|
||||
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
||||
if yj.player != nil {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
|
||||
// applyJobPrefix namespaces autotag apply jobs in the shared registry.
|
||||
const applyJobPrefix = "autotag:"
|
||||
|
||||
// SetJobRegistry wires the background job registry so an apply reports
|
||||
// progress and offers a cancel like every other long-running operation.
|
||||
//
|
||||
// Before this, apply was a bare goroutine whose progress lived in a
|
||||
// component field that navigation discarded, with no cancel and no
|
||||
// record of where it stopped (errors.C3). Everything routed through the
|
||||
// registry gets progress, cancel and the global indicator for free; the
|
||||
// three subsystems that lacked them were the three that were not
|
||||
// registered.
|
||||
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||
s.mu.Lock()
|
||||
s.jobsReg = reg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// applyJobID is the registry ID for one folder's apply.
|
||||
func applyJobID(groupKey string) string {
|
||||
return applyJobPrefix + groupKey
|
||||
}
|
||||
|
||||
// WritesInFlight reports whether an apply is currently rewriting tags
|
||||
// on disk. Quitting mid-apply leaves a folder half-retagged, so the app
|
||||
// asks before closing (errors.p4).
|
||||
func (s *Service) WritesInFlight() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return len(s.runningApplies) > 0
|
||||
}
|
||||
|
||||
// startApplyJob registers the job and returns the handle plus a context
|
||||
// the user's Cancel button can stop. A nil registry (tests, and the
|
||||
// window before wiring) degrades to the plain context.
|
||||
func (s *Service) startApplyJob(
|
||||
groupKey string,
|
||||
total int,
|
||||
) (*jobs.Handle, context.Context, context.CancelFunc) {
|
||||
s.mu.Lock()
|
||||
parent := s.ctx
|
||||
reg := s.jobsReg
|
||||
s.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
|
||||
if reg == nil {
|
||||
return nil, ctx, cancel
|
||||
}
|
||||
|
||||
handle := reg.Start(jobs.Spec{
|
||||
ID: applyJobID(groupKey),
|
||||
Kind: jobs.KindAutotagApply,
|
||||
Title: "Writing tags",
|
||||
Subtitle: folderLabel(groupKey),
|
||||
Total: int64(total),
|
||||
Caps: jobs.Caps{Cancellable: true},
|
||||
Controls: jobs.Controls{Cancel: cancel},
|
||||
})
|
||||
|
||||
return handle, ctx, cancel
|
||||
}
|
||||
|
||||
// folderLabel is the part of a group key worth showing: the folder,
|
||||
// not the whole path, which is usually wider than the row.
|
||||
func folderLabel(groupKey string) string {
|
||||
trimmed := strings.TrimRight(groupKey, "/")
|
||||
|
||||
if idx := strings.LastIndex(trimmed, "/"); idx >= 0 {
|
||||
return trimmed[idx+1:]
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
|
||||
var errApplyFailed = errors.New("write failed")
|
||||
|
||||
// newJobService builds the smallest Service that can register a job:
|
||||
// no database, no scorer, no MB client.
|
||||
func newJobService(t *testing.T) (*Service, *jobs.Registry) {
|
||||
t.Helper()
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
reg := jobs.NewRegistry(logger, nil)
|
||||
svc := &Service{
|
||||
logger: logger,
|
||||
ctx: context.Background(),
|
||||
runningApplies: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
svc.SetJobRegistry(reg)
|
||||
|
||||
return svc, reg
|
||||
}
|
||||
|
||||
func TestApplyJob_RegistersACancellableJob(t *testing.T) {
|
||||
svc, reg := newJobService(t)
|
||||
|
||||
handle, ctx, cancel := svc.startApplyJob("/music/Artist/Album", 9)
|
||||
defer cancel()
|
||||
|
||||
if handle == nil {
|
||||
t.Fatal("no job handle: an apply that is not registered has no cancel and no progress")
|
||||
}
|
||||
|
||||
snapshot := handle.Snapshot()
|
||||
|
||||
if snapshot.Kind != jobs.KindAutotagApply {
|
||||
t.Errorf("kind = %q, want %q", snapshot.Kind, jobs.KindAutotagApply)
|
||||
}
|
||||
|
||||
if snapshot.Total != 9 {
|
||||
t.Errorf("total = %d, want 9", snapshot.Total)
|
||||
}
|
||||
|
||||
if !snapshot.Caps.Cancellable {
|
||||
t.Error("apply job is not cancellable, which is the point of registering it")
|
||||
}
|
||||
|
||||
if snapshot.Subtitle != "Album" {
|
||||
t.Errorf("subtitle = %q, want the folder name", snapshot.Subtitle)
|
||||
}
|
||||
|
||||
// The registry's Cancel control has to reach the context the apply
|
||||
// is running under, or the button is decoration.
|
||||
reg.Cancel(applyJobID("/music/Artist/Album"))
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func TestApplyJob_FinishStateMatchesTheRun(t *testing.T) {
|
||||
cancelled, cancelStop := context.WithCancel(context.Background())
|
||||
cancelStop()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
result *autotag.ApplyResult
|
||||
err error
|
||||
want jobs.State
|
||||
}{
|
||||
{
|
||||
name: "every track written",
|
||||
ctx: context.Background(),
|
||||
result: &autotag.ApplyResult{Succeeded: 4},
|
||||
want: jobs.StateComplete,
|
||||
},
|
||||
{
|
||||
name: "some tracks failed",
|
||||
ctx: context.Background(),
|
||||
result: &autotag.ApplyResult{Succeeded: 3, Failed: 1},
|
||||
want: jobs.StateComplete,
|
||||
},
|
||||
{
|
||||
name: "the apply itself failed",
|
||||
ctx: context.Background(),
|
||||
err: errApplyFailed,
|
||||
want: jobs.StateError,
|
||||
},
|
||||
{
|
||||
// Cancelled beats failed: Apply returns a context error on
|
||||
// the way out, and reading that as a failure would make
|
||||
// every cancel look like a bug.
|
||||
name: "the user cancelled",
|
||||
ctx: cancelled,
|
||||
result: &autotag.ApplyResult{Succeeded: 1},
|
||||
err: context.Canceled,
|
||||
want: jobs.StateCancelled,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, _ := newJobService(t)
|
||||
handle, _, cancel := svc.startApplyJob("/music/"+tt.name, 4)
|
||||
|
||||
defer cancel()
|
||||
|
||||
svc.finishApplyJob(tt.ctx, handle, tt.result, tt.err)
|
||||
|
||||
if got := handle.State(); got != tt.want {
|
||||
t.Errorf("state = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritesInFlight_TracksTheApplySet(t *testing.T) {
|
||||
svc, _ := newJobService(t)
|
||||
|
||||
if svc.WritesInFlight() {
|
||||
t.Fatal("nothing is running, so nothing should be reported in flight")
|
||||
}
|
||||
|
||||
svc.tryStartApply("/music/Album")
|
||||
|
||||
if !svc.WritesInFlight() {
|
||||
t.Error("an apply is running: quitting now would half-retag a folder")
|
||||
}
|
||||
|
||||
svc.endApply("/music/Album")
|
||||
|
||||
if svc.WritesInFlight() {
|
||||
t.Error("the apply finished and the app should stop asking about it")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/jobs"
|
||||
"yellowjacket/backend/metadata"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
@@ -84,6 +85,10 @@ type Service struct {
|
||||
logger *slog.Logger
|
||||
ctx context.Context
|
||||
|
||||
// Registry for the apply job, wired after construction like every
|
||||
// other subsystem's. Guarded by mu.
|
||||
jobsReg *jobs.Registry
|
||||
|
||||
// Queue cursor — the group_key of the last item returned.
|
||||
// GetNextPending uses it to advance. Reset by StartAutotagQueue.
|
||||
mu sync.Mutex
|
||||
@@ -1103,7 +1108,9 @@ func (s *Service) ApplyAsync(groupKey, releaseMBID string) error {
|
||||
"total": total,
|
||||
})
|
||||
|
||||
go s.runApply(groupKey, plan, total)
|
||||
handle, ctx, cancel := s.startApplyJob(groupKey, total)
|
||||
|
||||
go s.runApply(ctx, cancel, handle, groupKey, plan, total)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1147,10 +1154,26 @@ func (s *Service) prepareApplyPlan(
|
||||
// runApply executes the plan in the background and emits progress
|
||||
// + completion events. Always releases the in-flight slot when
|
||||
// it returns, even on panic.
|
||||
func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int) {
|
||||
//
|
||||
// The job handle is the same progress and cancel surface every other
|
||||
// long-running operation has; the events stay because the autotag page
|
||||
// drives its per-folder row from them.
|
||||
func (s *Service) runApply(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
handle *jobs.Handle,
|
||||
groupKey string,
|
||||
plan *autotag.ApplyPlan,
|
||||
total int,
|
||||
) {
|
||||
defer s.endApply(groupKey)
|
||||
defer cancel()
|
||||
|
||||
onProgress := func(current, total, succeeded, failed int) {
|
||||
if handle != nil {
|
||||
handle.SetProgress(int64(current), int64(total))
|
||||
}
|
||||
|
||||
s.emitEvent(events.AutotagApplyProgress, map[string]any{
|
||||
"groupKey": groupKey,
|
||||
"current": current,
|
||||
@@ -1160,7 +1183,7 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
})
|
||||
}
|
||||
|
||||
result, err := s.applier.Apply(s.ctx, plan, onProgress)
|
||||
result, err := s.applier.Apply(ctx, plan, onProgress)
|
||||
|
||||
finished := map[string]any{
|
||||
"groupKey": groupKey,
|
||||
@@ -1178,9 +1201,40 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
finished["error"] = err.Error()
|
||||
}
|
||||
|
||||
s.finishApplyJob(ctx, handle, result, err)
|
||||
|
||||
s.emitEvent(events.AutotagApplyFinished, finished)
|
||||
}
|
||||
|
||||
// finishApplyJob closes the job out in the state the run ended in, so
|
||||
// a cancelled apply reads as cancelled rather than as a failure and a
|
||||
// partial write says how far it got.
|
||||
func (s *Service) finishApplyJob(
|
||||
ctx context.Context,
|
||||
handle *jobs.Handle,
|
||||
result *autotag.ApplyResult,
|
||||
err error,
|
||||
) {
|
||||
if handle == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
handle.Cancelled()
|
||||
case err != nil:
|
||||
handle.Fail(err)
|
||||
case result != nil && result.Failed > 0:
|
||||
handle.Logf(jobs.LevelWarn, fmt.Sprintf(
|
||||
"%d of %d tracks could not be written",
|
||||
result.Failed, result.Succeeded+result.Failed,
|
||||
))
|
||||
handle.Complete()
|
||||
default:
|
||||
handle.Complete()
|
||||
}
|
||||
}
|
||||
|
||||
// tryStartApply records that an Apply for the given group is
|
||||
// running. Returns false when a previous job for the same key
|
||||
// hasn't finished yet — caller should treat that as
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeVerb matches the first SQL keyword of a statement that mutates.
|
||||
// Anchored to the start of the trimmed line, because a subquery or a
|
||||
// column named "update" is not a write.
|
||||
var writeVerb = regexp.MustCompile(
|
||||
`^\s*` + "`" + `?\s*(?i:INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER)\s`,
|
||||
)
|
||||
|
||||
// queryCall matches a call to one of the read-pool helpers. These
|
||||
// route to DB.reader(), which in a real app is a second sql.DB opened
|
||||
// query-only over the same file.
|
||||
var queryCall = regexp.MustCompile(
|
||||
`\.Query(?:Context|ContextWith|Row)\s*\(`,
|
||||
)
|
||||
|
||||
// TestNoWritesOnTheReadPool fails if a mutating statement is issued
|
||||
// through one of the query-only read helpers.
|
||||
//
|
||||
// This is worth a test of its own because the failure mode is invisible
|
||||
// to every other tier. `CreateSmartPlaylist` ran an
|
||||
// `INSERT ... RETURNING` through `QueryContext` — a write wearing a
|
||||
// query's shape — and failed at runtime with "attempt to write a
|
||||
// readonly database (8)", i.e. no smart playlist could be created at
|
||||
// all. Nothing caught it: `NewTestDB` shares one in-memory connection
|
||||
// and sets `readDB` to nil, so `reader()` returns the *writer* there
|
||||
// and every unit test of that path passed against a handle the app does
|
||||
// not have.
|
||||
//
|
||||
// A text walk rather than a lint rule, for the same reason as
|
||||
// TestNoDirectRuntimeEmits: golangci-lint runs once per build
|
||||
// configuration and would not see a call in a tagged file.
|
||||
func TestNoWritesOnTheReadPool(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
|
||||
skipDirs := map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
"frontend": true,
|
||||
"build": true,
|
||||
".dev": true,
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if filepath.Ext(path) != ".go" ||
|
||||
strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
|
||||
src, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
lines := strings.Split(string(src), "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
if !queryCall.MatchString(line) ||
|
||||
strings.Contains(line, "QueryRowWriter") {
|
||||
continue
|
||||
}
|
||||
|
||||
// The statement is usually on the following line, in a raw
|
||||
// string literal. Look a little way ahead rather than only
|
||||
// at the call itself.
|
||||
for j := i; j < min(i+3, len(lines)); j++ {
|
||||
if writeVerb.MatchString(lines[j]) {
|
||||
t.Errorf(
|
||||
"%s:%d issues a write through a read-pool helper; "+
|
||||
"use ExecContext or QueryRowWriter\n\t%s",
|
||||
rel, j+1, strings.TrimSpace(lines[j]),
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walking %s: %v", root, err)
|
||||
}
|
||||
}
|
||||
@@ -295,3 +295,26 @@ LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE rgr.release_group_id = ? AND af.library_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- "Play this artist" and "play these albums" wanted file paths and asked
|
||||
-- for whole track rows to get them, one round trip per album (perf.m2).
|
||||
-- These answer the same question in one query and carry only what the
|
||||
-- caller uses; the release group id comes back so the caller can keep
|
||||
-- its own album ordering.
|
||||
|
||||
-- name: GetFilePathsByReleaseGroups :many
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||
AND af.library_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
@@ -125,3 +125,26 @@ JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE af.library_id = ?
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name;
|
||||
|
||||
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
||||
-- one query instead of one per genre, and file paths instead of whole
|
||||
-- track rows, which was 6 MB over the IPC for five genres.
|
||||
|
||||
-- name: GetFilePathsByGenres :many
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||
ORDER BY r.name;
|
||||
|
||||
-- name: GetFilePathsByGenresByLibrary :many
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||
AND af.library_id = ?
|
||||
ORDER BY r.name;
|
||||
|
||||
@@ -907,6 +907,113 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByReleaseGroups = `-- name: GetFilePathsByReleaseGroups :many
|
||||
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (/*SLICE:release_group_ids*/?)
|
||||
ORDER BY rgr.disc_number, rgr.track_number
|
||||
`
|
||||
|
||||
type GetFilePathsByReleaseGroupsRow struct {
|
||||
ReleaseGroupID int64
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// "Play this artist" and "play these albums" wanted file paths and asked
|
||||
// for whole track rows to get them, one round trip per album (perf.m2).
|
||||
// These answer the same question in one query and carry only what the
|
||||
// caller uses; the release group id comes back so the caller can keep
|
||||
// its own album ordering.
|
||||
func (q *Queries) GetFilePathsByReleaseGroups(ctx context.Context, releaseGroupIds []int64) ([]GetFilePathsByReleaseGroupsRow, error) {
|
||||
query := getFilePathsByReleaseGroups
|
||||
var queryParams []interface{}
|
||||
if len(releaseGroupIds) > 0 {
|
||||
for _, v := range releaseGroupIds {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(releaseGroupIds))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1)
|
||||
}
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByReleaseGroupsRow
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByReleaseGroupsRow
|
||||
if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByReleaseGroupsByLibrary = `-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (/*SLICE:release_group_ids*/?)
|
||||
AND af.library_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number
|
||||
`
|
||||
|
||||
type GetFilePathsByReleaseGroupsByLibraryParams struct {
|
||||
ReleaseGroupIds []int64
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
type GetFilePathsByReleaseGroupsByLibraryRow struct {
|
||||
ReleaseGroupID int64
|
||||
FilePath string
|
||||
}
|
||||
|
||||
func (q *Queries) GetFilePathsByReleaseGroupsByLibrary(ctx context.Context, arg GetFilePathsByReleaseGroupsByLibraryParams) ([]GetFilePathsByReleaseGroupsByLibraryRow, error) {
|
||||
query := getFilePathsByReleaseGroupsByLibrary
|
||||
var queryParams []interface{}
|
||||
if len(arg.ReleaseGroupIds) > 0 {
|
||||
for _, v := range arg.ReleaseGroupIds {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(arg.ReleaseGroupIds))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1)
|
||||
}
|
||||
queryParams = append(queryParams, arg.LibraryID)
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByReleaseGroupsByLibraryRow
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByReleaseGroupsByLibraryRow
|
||||
if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLibraryMaxModifiedAt = `-- name: GetLibraryMaxModifiedAt :one
|
||||
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
||||
WHERE library_id = ?
|
||||
|
||||
@@ -8,6 +8,7 @@ package sqlcgen
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const countGenreReferences = `-- name: CountGenreReferences :one
|
||||
@@ -148,6 +149,113 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many
|
||||
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
||||
ORDER BY r.name
|
||||
`
|
||||
|
||||
type GetFilePathsByGenresRow struct {
|
||||
GenreName string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
||||
// one query instead of one per genre, and file paths instead of whole
|
||||
// track rows, which was 6 MB over the IPC for five genres.
|
||||
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
|
||||
query := getFilePathsByGenres
|
||||
var queryParams []interface{}
|
||||
if len(genreNames) > 0 {
|
||||
for _, v := range genreNames {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||
}
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByGenresRow
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByGenresRow
|
||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
||||
AND af.library_id = ?
|
||||
ORDER BY r.name
|
||||
`
|
||||
|
||||
type GetFilePathsByGenresByLibraryParams struct {
|
||||
GenreNames []string
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
type GetFilePathsByGenresByLibraryRow struct {
|
||||
GenreName string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
|
||||
query := getFilePathsByGenresByLibrary
|
||||
var queryParams []interface{}
|
||||
if len(arg.GenreNames) > 0 {
|
||||
for _, v := range arg.GenreNames {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||
}
|
||||
queryParams = append(queryParams, arg.LibraryID)
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByGenresByLibraryRow
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByGenresByLibraryRow
|
||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
||||
SELECT g.id, g.name
|
||||
FROM genres g
|
||||
|
||||
@@ -120,6 +120,28 @@ func cleanComment(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// commentLines renders a doc comment as indented TypeScript line
|
||||
// comments, one per source line.
|
||||
func commentLines(comment string) []string {
|
||||
if comment == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
|
||||
for _, line := range strings.Split(comment, "\n") {
|
||||
if line == "" {
|
||||
out = append(out, " //")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, " // "+line)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// generateTypeScript produces the full TypeScript source from the parsed
|
||||
// constant groups.
|
||||
func generateTypeScript(groups []constGroup) string {
|
||||
@@ -130,8 +152,12 @@ func generateTypeScript(groups []constGroup) string {
|
||||
b.WriteString("export const Events = {\n")
|
||||
|
||||
for i, g := range groups {
|
||||
if g.Comment != "" {
|
||||
b.WriteString(" // " + g.Comment + "\n")
|
||||
// Every line, not just the first: a doc comment that runs to a
|
||||
// second paragraph used to emit its remainder as bare prose
|
||||
// inside the object literal, so `make generate` — a pre-commit
|
||||
// hook — produced TypeScript that does not parse.
|
||||
for _, line := range commentLines(g.Comment) {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
for _, c := range g.Consts {
|
||||
|
||||
@@ -13,6 +13,20 @@ const (
|
||||
SeekFailed = "SeekFailed"
|
||||
VolumeChanged = "VolumeChanged"
|
||||
MuteChanged = "MuteChanged"
|
||||
|
||||
// PlaybackPositionChanged carries the player's own position
|
||||
// (payload: player.PositionInfo) once a second while playing and
|
||||
// immediately after any seek, pause, resume or track change. The
|
||||
// seek bar renders what it is told and interpolates only between
|
||||
// ticks, so it can be at most one tick wrong and can never
|
||||
// accumulate error the way a pure local counter did.
|
||||
PlaybackPositionChanged = "PlaybackPositionChanged"
|
||||
|
||||
// PlaybackFailed (payload: {filePath, reason}) fires when a track
|
||||
// could not be loaded or started — a moved file, an unreadable
|
||||
// one, an unsupported codec. Without it the failure was a silent
|
||||
// no-op: the queue reverted its index and nothing reached the UI.
|
||||
PlaybackFailed = "PlaybackFailed"
|
||||
)
|
||||
|
||||
// Queue events (backend → frontend push).
|
||||
@@ -70,11 +84,31 @@ const (
|
||||
)
|
||||
|
||||
// Tag writing events.
|
||||
//
|
||||
// TrackMetadataChanged means "tags on disk were rewritten", and the
|
||||
// frontend answers it by throwing the whole library cache away and
|
||||
// refetching — which is correct, because a retag can change an album
|
||||
// name, an artist, a genre, and therefore every derived collection.
|
||||
//
|
||||
// It must therefore not be reused for anything cheaper. Finishing a
|
||||
// track used to emit it, so every song cost a full refetch: ~37 MB
|
||||
// across the IPC per track at 50 000 tracks, and the user's track
|
||||
// selection cleared while music played (audit perf.C1/C2). That is
|
||||
// what TrackPlayCountChanged below exists to separate.
|
||||
const (
|
||||
TrackMetadataChanged = "TrackMetadataChanged"
|
||||
BatchWriteProgress = "BatchWriteProgress"
|
||||
)
|
||||
|
||||
// Play statistics events.
|
||||
//
|
||||
// TrackPlayCountChanged carries everything needed to patch the one
|
||||
// track in place, precisely so no consumer has any reason to invalidate
|
||||
// a collection: {audioFileId, filePath, playCount, lastPlayed}.
|
||||
const (
|
||||
TrackPlayCountChanged = "TrackPlayCountChanged"
|
||||
)
|
||||
|
||||
// Autotag apply events — emitted while an async ApplyAsync job is in flight so the review UI can render per-folder progress.
|
||||
const (
|
||||
AutotagApplyStarted = "AutotagApplyStarted" // {groupKey: string, total: int}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// The index status used to be pushed on a 3 s ticker for the life of
|
||||
// the process, with a byte-identical payload once the index was ready.
|
||||
// The frontend's handler assigns it to a @state field, so every tick
|
||||
// re-rendered the whole settings page — a cached view that never
|
||||
// unmounts — saying nothing (`perf.M6` / `H-14`).
|
||||
//
|
||||
// The ticker is gone and emitStatus suppresses an unchanged payload, so
|
||||
// what these cover is the pair of properties that replaced it: a change
|
||||
// still gets through, and a non-change does not.
|
||||
|
||||
// setupRecordedIndex builds a SearchIndex with an event sink installed,
|
||||
// so what the frontend would receive is assertable in-process.
|
||||
func setupRecordedIndex(t *testing.T) (*SearchIndex, *events.Recorder) {
|
||||
t.Helper()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, slog.Default())
|
||||
|
||||
rec := events.NewRecorder()
|
||||
si.SetContext(events.WithSink(context.Background(), rec))
|
||||
|
||||
return si, rec
|
||||
}
|
||||
|
||||
func TestEmitStatus_SuppressesAnUnchangedPayload(t *testing.T) {
|
||||
si, rec := setupRecordedIndex(t)
|
||||
|
||||
// SetContext emits once via refreshStatusCounts; everything after
|
||||
// this describes the same state.
|
||||
rec.Reset()
|
||||
|
||||
for range 5 {
|
||||
si.emitStatus()
|
||||
}
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 0 {
|
||||
t.Fatalf(
|
||||
"emitted %d IndexStatusChanged for an unchanged status, want 0",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitStatus_EmitsOnChange(t *testing.T) {
|
||||
si, rec := setupRecordedIndex(t)
|
||||
rec.Reset()
|
||||
|
||||
si.setTierStatus("artists", "running", 100, 10)
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 1 {
|
||||
t.Fatalf("emitted %d IndexStatusChanged for a new tier, want 1", got)
|
||||
}
|
||||
|
||||
// Progress within the tier is a change too — this is what a build
|
||||
// reports, and dropping it would freeze the progress bar.
|
||||
si.setTierStatus("artists", "running", 100, 20)
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 2 {
|
||||
t.Fatalf("emitted %d after progress moved, want 2", got)
|
||||
}
|
||||
|
||||
// The same call again is not.
|
||||
si.setTierStatus("artists", "running", 100, 20)
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 2 {
|
||||
t.Fatalf("emitted %d after a repeated status, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The status carries a slice, so a snapshot that aliases it would
|
||||
// compare equal to a later mutation of the same backing array and the
|
||||
// change would never be emitted.
|
||||
func TestEmitStatus_SnapshotDoesNotAliasTiers(t *testing.T) {
|
||||
si, rec := setupRecordedIndex(t)
|
||||
|
||||
si.setTierStatus("artists", "running", 100, 10)
|
||||
rec.Reset()
|
||||
|
||||
si.setTierStatus("artists", "complete", 100, 100)
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 1 {
|
||||
t.Fatalf(
|
||||
"emitted %d when a tier completed in place, want 1", got,
|
||||
)
|
||||
}
|
||||
|
||||
status, ok := rec.Last(events.IndexStatusChanged)
|
||||
if !ok {
|
||||
t.Fatal("no IndexStatusChanged recorded")
|
||||
}
|
||||
|
||||
payload, ok := status.Payload().(IndexStatus)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want explore.IndexStatus", status.Payload())
|
||||
}
|
||||
|
||||
if len(payload.Tiers) != 1 || payload.Tiers[0].State != "complete" {
|
||||
t.Fatalf("payload tiers = %+v, want one complete tier", payload.Tiers)
|
||||
}
|
||||
}
|
||||
|
||||
// `Building` and `Ready` are derived inside emitStatus from fields the
|
||||
// dedupe never sees directly, so a transition in either has to survive
|
||||
// it. A build ending is the case that matters: syncIndexJob only
|
||||
// resolves the job in the registry on a sync reporting Building false,
|
||||
// and suppressing that left the header badge saying "Building search
|
||||
// index" over an index the settings page called ready.
|
||||
func TestEmitStatus_BuildingTransitionsAreNotSuppressed(t *testing.T) {
|
||||
si, rec := setupRecordedIndex(t)
|
||||
|
||||
si.mu.Lock()
|
||||
_, si.cancel = context.WithCancel(context.Background())
|
||||
si.mu.Unlock()
|
||||
|
||||
si.emitStatus()
|
||||
rec.Reset()
|
||||
|
||||
// Nothing else changed, so this one is noise.
|
||||
si.emitStatus()
|
||||
|
||||
if got := rec.Count(events.IndexStatusChanged); got != 0 {
|
||||
t.Fatalf("emitted %d while still building unchanged, want 0", got)
|
||||
}
|
||||
|
||||
si.mu.Lock()
|
||||
si.cancel = nil
|
||||
si.mu.Unlock()
|
||||
|
||||
si.emitStatus()
|
||||
|
||||
ev, ok := rec.Last(events.IndexStatusChanged)
|
||||
if !ok {
|
||||
t.Fatalf("a build ending emitted nothing; got %v", rec.Names())
|
||||
}
|
||||
|
||||
payload, ok := ev.Payload().(IndexStatus)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want explore.IndexStatus", ev.Payload())
|
||||
}
|
||||
|
||||
if payload.Building {
|
||||
t.Fatal("payload still reports building after the build ended")
|
||||
}
|
||||
}
|
||||
|
||||
// Becoming ready used to be the one status mutation with no emit behind
|
||||
// it; the ticker carried it, so removing the ticker without an explicit
|
||||
// emit would have left the settings page reading "not ready" forever
|
||||
// over a fully built index.
|
||||
func TestEmitStatus_ReadyIsAChange(t *testing.T) {
|
||||
si, rec := setupRecordedIndex(t)
|
||||
rec.Reset()
|
||||
|
||||
si.mu.Lock()
|
||||
si.ready = true
|
||||
si.mu.Unlock()
|
||||
|
||||
si.emitStatus()
|
||||
|
||||
ev, ok := rec.Last(events.IndexStatusChanged)
|
||||
if !ok {
|
||||
t.Fatalf("becoming ready emitted nothing; got %v", rec.Names())
|
||||
}
|
||||
|
||||
payload, ok := ev.Payload().(IndexStatus)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want explore.IndexStatus", ev.Payload())
|
||||
}
|
||||
|
||||
if !payload.Ready {
|
||||
t.Fatal("payload reports not ready after si.ready was set")
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,13 @@ type SearchIndex struct {
|
||||
// Build status tracking — read by GetIndexStatus for the UI.
|
||||
buildStatus IndexStatus
|
||||
|
||||
// lastEmitted is the status most recently pushed to the frontend, so
|
||||
// an unchanged one can be dropped rather than re-rendering the whole
|
||||
// settings page for nothing. It has its own mutex: emitStatus is
|
||||
// called from paths that already hold mu for reading.
|
||||
emitMu sync.Mutex
|
||||
lastEmitted *IndexStatus
|
||||
|
||||
// jobs is the background job registry; buildPaused records that the
|
||||
// user paused the build, distinguishing a deliberate stop from a
|
||||
// build that merely finished. Both are protected by mu.
|
||||
@@ -267,24 +274,16 @@ func (si *SearchIndex) SetContext(ctx context.Context) {
|
||||
si.mu.Unlock()
|
||||
|
||||
// Load current row counts + last-built timestamp from DB.
|
||||
//
|
||||
// There is deliberately no ticker here. This used to emit the status
|
||||
// every 3 seconds for the life of the process, with a byte-identical
|
||||
// payload once the index was ready — which re-rendered the whole of
|
||||
// `config-page` on every tick, since it is a cached view that never
|
||||
// unmounts. Every path that mutates the status already calls
|
||||
// emitStatus, that call now suppresses an unchanged payload, and the
|
||||
// frontend seeds itself with GetIndexStatus() on connect rather than
|
||||
// waiting for the next tick.
|
||||
si.refreshStatusCounts()
|
||||
|
||||
// Start a background ticker that emits status every 3 seconds.
|
||||
// This replaces frontend polling — the Wails binding dispatcher
|
||||
// can be blocked by other calls, but EventsEmit bypasses it.
|
||||
go func() {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
si.emitStatus()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// EnsureArtistDiscography lazily fetches an artist's top release groups
|
||||
@@ -522,6 +521,14 @@ func (si *SearchIndex) StartBuild(ctx context.Context) {
|
||||
si.cancel = nil
|
||||
si.mu.Unlock()
|
||||
|
||||
// `Building` is derived from si.cancel, so clearing it is a
|
||||
// status change and has to say so. This is what resolves the
|
||||
// job in the registry — syncIndexJob only finishes a job on a
|
||||
// sync that reports Building false, and without this line the
|
||||
// header badge reads "Building search index" over an index the
|
||||
// settings page calls ready.
|
||||
si.emitStatus()
|
||||
|
||||
close(si.done)
|
||||
|
||||
// The index rows (and their popularities) may have changed, so
|
||||
@@ -677,7 +684,38 @@ func (si *SearchIndex) setTierDetail(name, state string, total, completed int, d
|
||||
si.emitStatus()
|
||||
}
|
||||
|
||||
// emitStatus pushes the current index status to the frontend via Wails event.
|
||||
// sameStatusAs reports whether two statuses would render identically.
|
||||
// IndexStatus holds a slice, so it is not comparable with ==.
|
||||
func (s IndexStatus) sameStatusAs(o IndexStatus) bool {
|
||||
if s.Building != o.Building ||
|
||||
s.Ready != o.Ready ||
|
||||
s.LastBuilt != o.LastBuilt ||
|
||||
s.Artists != o.Artists ||
|
||||
s.Recordings != o.Recordings ||
|
||||
s.ReleaseGroups != o.ReleaseGroups ||
|
||||
s.TotalRows != o.TotalRows ||
|
||||
len(s.Tiers) != len(o.Tiers) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range s.Tiers {
|
||||
if s.Tiers[i] != o.Tiers[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// emitStatus pushes the current index status to the frontend via Wails
|
||||
// event — but only when it differs from the last one pushed.
|
||||
//
|
||||
// The status is emitted from every path that touches it, several of
|
||||
// which report progress in a tight loop, and the frontend's handler
|
||||
// assigns to a @state field: an identical payload is therefore a full
|
||||
// re-render of a 2 000-line template saying nothing. Deduplicating
|
||||
// here rather than at the call sites means no future emitter has to
|
||||
// remember (`perf.M6` / `H-14`).
|
||||
func (si *SearchIndex) emitStatus() {
|
||||
if si.runtimeCtx == nil {
|
||||
return
|
||||
@@ -689,6 +727,23 @@ func (si *SearchIndex) emitStatus() {
|
||||
status.Building = si.cancel != nil
|
||||
si.mu.RUnlock()
|
||||
|
||||
si.emitMu.Lock()
|
||||
unchanged := si.lastEmitted != nil &&
|
||||
si.lastEmitted.sameStatusAs(status)
|
||||
|
||||
if !unchanged {
|
||||
snapshot := status
|
||||
snapshot.Tiers = append(
|
||||
[]TierStatus(nil), status.Tiers...,
|
||||
)
|
||||
si.lastEmitted = &snapshot
|
||||
}
|
||||
si.emitMu.Unlock()
|
||||
|
||||
if unchanged {
|
||||
return
|
||||
}
|
||||
|
||||
events.Emit(si.runtimeCtx, events.IndexStatusChanged, status)
|
||||
|
||||
// Mirror into the shared job registry. Every status mutation goes
|
||||
@@ -2644,6 +2699,13 @@ func (si *SearchIndex) MarkReadyIfPopulated() {
|
||||
|
||||
si.logger.Info("search index: using existing index", "entries", count)
|
||||
|
||||
// Becoming ready is a change the UI has to see, and this was the one
|
||||
// path that mutated the status without saying so — the 3 s ticker
|
||||
// carried it, invisibly, which is why removing the ticker without
|
||||
// this line would have left the settings page reading "not ready"
|
||||
// over a fully built index.
|
||||
si.emitStatus()
|
||||
|
||||
if !championBuilt {
|
||||
si.scheduleChampionRebuild()
|
||||
}
|
||||
|
||||
@@ -22,9 +22,10 @@ type Kind string
|
||||
|
||||
// Job kinds.
|
||||
const (
|
||||
KindLibraryScan Kind = "library-scan"
|
||||
KindIndexBuild Kind = "index-build"
|
||||
KindDownload Kind = "download"
|
||||
KindLibraryScan Kind = "library-scan"
|
||||
KindIndexBuild Kind = "index-build"
|
||||
KindDownload Kind = "download"
|
||||
KindAutotagApply Kind = "autotag-apply"
|
||||
)
|
||||
|
||||
// State is the lifecycle position of a job.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// seedAlbumsAndGenres builds two albums in two libraries, with one track
|
||||
// carrying two genres — enough shape for the batched path lookups to be
|
||||
// wrong in an interesting way if they group or filter incorrectly.
|
||||
func seedAlbumsAndGenres(t *testing.T, lib *Library) (albumIDs []int64, libraryID int64) {
|
||||
t.Helper()
|
||||
|
||||
ctx := lib.ctx
|
||||
q := lib.db.Queries
|
||||
|
||||
library, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
|
||||
Name: "Main",
|
||||
Path: "/music",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
|
||||
other, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
|
||||
Name: "Other",
|
||||
Path: "/other",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create other library: %v", err)
|
||||
}
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
genreIDs := map[string]int64{}
|
||||
|
||||
for _, name := range []string{"Ambient", "Baroque"} {
|
||||
g, err := q.UpsertGenre(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert genre %s: %v", name, err)
|
||||
}
|
||||
|
||||
genreIDs[name] = g.ID
|
||||
}
|
||||
|
||||
// Two albums; the second lives in the other library so the
|
||||
// library-scoped variants have something to exclude.
|
||||
type spec struct {
|
||||
album string
|
||||
track string
|
||||
path string
|
||||
library int64
|
||||
disc int64
|
||||
number int64
|
||||
genres []string
|
||||
}
|
||||
|
||||
specs := []spec{
|
||||
{"First", "A2", "/music/a2.mp3", library.ID, 1, 2, []string{"Ambient"}},
|
||||
{"First", "A1", "/music/a1.mp3", library.ID, 1, 1, []string{"Ambient", "Baroque"}},
|
||||
{"Second", "B1", "/other/b1.mp3", other.ID, 1, 1, []string{"Baroque"}},
|
||||
}
|
||||
|
||||
byAlbum := map[string]int64{}
|
||||
|
||||
for _, s := range specs {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: s.track,
|
||||
ArtistCreditID: ac.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
rgID, ok := byAlbum[s.album]
|
||||
|
||||
if !ok {
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: s.album,
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert release group: %v", err)
|
||||
}
|
||||
|
||||
rgID = rg.ID
|
||||
byAlbum[s.album] = rgID
|
||||
albumIDs = append(albumIDs, rgID)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(
|
||||
ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rgID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: s.number, Valid: true},
|
||||
DiscNumber: sql.NullInt64{Int64: s.disc, Valid: true},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("link recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: s.path,
|
||||
LengthMilliseconds: 1000,
|
||||
RecordingID: rec.ID,
|
||||
LibraryID: s.library,
|
||||
Basename: s.track + ".mp3",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
|
||||
for _, g := range s.genres {
|
||||
if err := q.CreateRecordingGenre(
|
||||
ctx, sqlcgen.CreateRecordingGenreParams{
|
||||
RecordingID: rec.ID,
|
||||
GenreID: genreIDs[g],
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("link genre: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return albumIDs, library.ID
|
||||
}
|
||||
|
||||
// perf.m2: "play this artist" asked for whole track rows, one round trip
|
||||
// per album, to read one field off each. These two answer in one query,
|
||||
// and the thing worth pinning is that they still group by the entity the
|
||||
// caller ordered by — a flattened result would silently reorder a queue.
|
||||
func TestGetFilePathsByAlbums(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
albumIDs, libraryID := seedAlbumsAndGenres(t, lib)
|
||||
|
||||
got, err := lib.GetFilePathsByAlbums(albumIDs, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByAlbums: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("albums returned = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Ordered by disc then track within an album, not by insertion.
|
||||
first := got[albumIDs[0]]
|
||||
if len(first) != 2 || first[0] != "/music/a1.mp3" || first[1] != "/music/a2.mp3" {
|
||||
t.Errorf("first album paths = %v, want [a1 a2] in track order", first)
|
||||
}
|
||||
|
||||
scoped, err := lib.GetFilePathsByAlbums(albumIDs, libraryID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByAlbums scoped: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := scoped[albumIDs[1]]; ok {
|
||||
t.Errorf("library-scoped result includes an album from another library")
|
||||
}
|
||||
|
||||
if len(scoped[albumIDs[0]]) != 2 {
|
||||
t.Errorf("scoped first album = %v, want 2 paths", scoped[albumIDs[0]])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilePathsByAlbums_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
|
||||
got, err := lib.GetFilePathsByAlbums(nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByAlbums(nil): %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilePathsByGenres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
_, libraryID := seedAlbumsAndGenres(t, lib)
|
||||
|
||||
got, err := lib.GetFilePathsByGenres([]string{"Ambient", "Baroque"}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByGenres: %v", err)
|
||||
}
|
||||
|
||||
if len(got["Ambient"]) != 2 {
|
||||
t.Errorf("Ambient = %v, want 2 paths", got["Ambient"])
|
||||
}
|
||||
|
||||
// One track is in both genres: the overlap is returned under each,
|
||||
// because de-duplicating is the caller's job — it is the one that
|
||||
// knows the order the genres were selected in.
|
||||
if len(got["Baroque"]) != 2 {
|
||||
t.Errorf("Baroque = %v, want 2 paths", got["Baroque"])
|
||||
}
|
||||
|
||||
scoped, err := lib.GetFilePathsByGenres([]string{"Baroque"}, libraryID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByGenres scoped: %v", err)
|
||||
}
|
||||
|
||||
if len(scoped["Baroque"]) != 1 || scoped["Baroque"][0] != "/music/a1.mp3" {
|
||||
t.Errorf("scoped Baroque = %v, want just the main library's track", scoped["Baroque"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilePathsByGenres_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
|
||||
got, err := lib.GetFilePathsByGenres(nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFilePathsByGenres(nil): %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1083,3 +1083,124 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFilePathsByAlbums returns the file paths of every track in the
|
||||
// given albums, grouped by album id.
|
||||
//
|
||||
// "Play this artist", "play these albums" and the album drag cache each
|
||||
// resolved paths with one binding call per album, sequentially, and each
|
||||
// asked for whole track rows to read one field off them (perf.m2). This
|
||||
// is that question asked once. The result is grouped rather than
|
||||
// flattened because the caller owns the ordering — an album list is
|
||||
// sorted by name, not by id — and because the drag cache stores it per
|
||||
// album.
|
||||
//
|
||||
// A library id of 0 means "every library", matching the caller's
|
||||
// selected-library filter being unset.
|
||||
func (l *Library) GetFilePathsByAlbums(
|
||||
albumIDs []int64, libraryID int64,
|
||||
) (map[int64][]string, error) {
|
||||
paths := make(map[int64][]string, len(albumIDs))
|
||||
|
||||
if len(albumIDs) == 0 {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
if libraryID > 0 {
|
||||
rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroupsByLibrary(
|
||||
l.ctx, sqlcgen.GetFilePathsByReleaseGroupsByLibraryParams{
|
||||
ReleaseGroupIds: albumIDs,
|
||||
LibraryID: libraryID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
l.logger.Error(
|
||||
"could not retrieve album file paths for library",
|
||||
"albums", len(albumIDs),
|
||||
"libraryID", libraryID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("could not get album file paths: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroups(l.ctx, albumIDs)
|
||||
if err != nil {
|
||||
l.logger.Error(
|
||||
"could not retrieve album file paths",
|
||||
"albums", len(albumIDs),
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("could not get album file paths: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// GetFilePathsByGenres returns the file paths of every track tagged with
|
||||
// the given genres, grouped by genre name. See GetFilePathsByAlbums —
|
||||
// same finding, same shape, and the caller still owns the de-duplication
|
||||
// across genres because it owns the order.
|
||||
func (l *Library) GetFilePathsByGenres(
|
||||
genreNames []string, libraryID int64,
|
||||
) (map[string][]string, error) {
|
||||
paths := make(map[string][]string, len(genreNames))
|
||||
|
||||
if len(genreNames) == 0 {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
if libraryID > 0 {
|
||||
rows, err := l.db.ReadQueries.GetFilePathsByGenresByLibrary(
|
||||
l.ctx, sqlcgen.GetFilePathsByGenresByLibraryParams{
|
||||
GenreNames: genreNames,
|
||||
LibraryID: libraryID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
l.logger.Error(
|
||||
"could not retrieve genre file paths for library",
|
||||
"genres", len(genreNames),
|
||||
"libraryID", libraryID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("could not get genre file paths: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
paths[row.GenreName] = append(paths[row.GenreName], row.FilePath)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
rows, err := l.db.ReadQueries.GetFilePathsByGenres(l.ctx, genreNames)
|
||||
if err != nil {
|
||||
l.logger.Error(
|
||||
"could not retrieve genre file paths",
|
||||
"genres", len(genreNames),
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("could not get genre file paths: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
paths[row.GenreName] = append(paths[row.GenreName], row.FilePath)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// recordedPlayer is a player with an event sink installed and no audio
|
||||
// device: enough to assert on what the frontend would receive from the
|
||||
// paths that do not touch the speaker.
|
||||
func recordedPlayer(t *testing.T) (*Player, *events.Recorder) {
|
||||
t.Helper()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
rec := events.NewRecorder()
|
||||
p.SetContext(events.WithSink(t.Context(), rec))
|
||||
|
||||
return p, rec
|
||||
}
|
||||
|
||||
func TestSeek_WithNoTrackEmitsSeekFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p, rec := recordedPlayer(t)
|
||||
|
||||
if err := p.Seek(5); err == nil {
|
||||
t.Fatal("Seek with no track loaded returned nil error")
|
||||
}
|
||||
|
||||
// C2: the frontend has made an optimistic move it now has to take
|
||||
// back, and this is the only thing that tells it so.
|
||||
if _, ok := rec.Last(events.SeekFailed); !ok {
|
||||
t.Errorf("no SeekFailed emitted; got %v", rec.Names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionTicker_SilentWhileNotPlaying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, rec := recordedPlayer(t)
|
||||
|
||||
// The ticker is running (SetContext started it) but nothing is
|
||||
// playing, so a paused app must not push a position a second
|
||||
// forever.
|
||||
time.Sleep(positionTickInterval * 2)
|
||||
|
||||
if got := rec.Count(events.PlaybackPositionChanged); got != 0 {
|
||||
t.Errorf("position emitted %d times while stopped, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitPosition_CarriesLengthAndSequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
rec := events.NewRecorder()
|
||||
p.ctx = events.WithSink(context.Background(), rec)
|
||||
|
||||
p.mu.Lock()
|
||||
p.emitPositionLocked()
|
||||
p.emitPositionLocked()
|
||||
p.mu.Unlock()
|
||||
|
||||
ticks := rec.Named(events.PlaybackPositionChanged)
|
||||
if len(ticks) != 2 {
|
||||
t.Fatalf("emitted %d positions, want 2", len(ticks))
|
||||
}
|
||||
|
||||
first, ok := ticks[0].Payload().(PositionInfo)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want player.PositionInfo", ticks[0].Payload())
|
||||
}
|
||||
|
||||
second, _ := ticks[1].Payload().(PositionInfo)
|
||||
|
||||
// The sequence is what lets the seek bar reset its interpolation
|
||||
// on a tick that reports the same second twice.
|
||||
if second.Seq <= first.Seq {
|
||||
t.Errorf("seq did not advance: %d then %d", first.Seq, second.Seq)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,26 @@ type Player struct {
|
||||
// inflated for files with multiple ID3v2 tags, so this value
|
||||
// is preferred for display and position calculations.
|
||||
trackLengthMs int64
|
||||
|
||||
// positionTickerOnce guards the 1 Hz position ticker so repeated
|
||||
// SetContext calls (tests, re-init) cannot start a second one.
|
||||
positionTickerOnce sync.Once
|
||||
|
||||
// positionSeq increments on every emitted position, so a
|
||||
// consumer can tell "the same second, again" from "a fresh
|
||||
// reading" and reset its interpolation on both.
|
||||
positionSeq uint64
|
||||
}
|
||||
|
||||
// PositionInfo is the payload of the PlaybackPositionChanged event:
|
||||
// the player's own answer to "where are we", which the seek bar
|
||||
// renders instead of counting.
|
||||
type PositionInfo struct {
|
||||
PositionSeconds int `json:"positionSeconds"`
|
||||
TrackLength int `json:"trackLength"`
|
||||
TrackChangeID uint64 `json:"trackChangeId"`
|
||||
Seq uint64 `json:"seq"`
|
||||
Playing bool `json:"playing"`
|
||||
}
|
||||
|
||||
// State represents the current playback state.
|
||||
@@ -173,6 +193,74 @@ func (p *Player) SetContext(ctx context.Context) {
|
||||
|
||||
p.ctx = ctx
|
||||
p.restoreStateLocked()
|
||||
p.startPositionTicker()
|
||||
}
|
||||
|
||||
// positionTickInterval is how often the backend reports its own
|
||||
// playback position while playing.
|
||||
const positionTickInterval = time.Second
|
||||
|
||||
// startPositionTicker runs the 1 Hz position report for the life of
|
||||
// the Wails context. Must be called with p.mu held.
|
||||
func (p *Player) startPositionTicker() {
|
||||
if p.ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := p.ctx
|
||||
|
||||
p.positionTickerOnce.Do(func() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(positionTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.emitPositionIfPlaying()
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// emitPositionIfPlaying reports the position only while audio is
|
||||
// actually moving; a paused or stopped player has already emitted its
|
||||
// final position at the transition.
|
||||
func (p *Player) emitPositionIfPlaying() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.state != Playing || p.currentFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.emitPositionLocked()
|
||||
}
|
||||
|
||||
// emitPositionLocked pushes the current position to the frontend.
|
||||
// Must be called with p.mu held.
|
||||
func (p *Player) emitPositionLocked() {
|
||||
if p.ctx == nil {
|
||||
return
|
||||
}
|
||||
|
||||
length, err := p.trackLengthLocked()
|
||||
if err != nil {
|
||||
length = 0
|
||||
}
|
||||
|
||||
p.positionSeq++
|
||||
|
||||
events.Emit(p.ctx, events.PlaybackPositionChanged, PositionInfo{
|
||||
PositionSeconds: p.displayPositionSecsLocked(),
|
||||
TrackLength: length,
|
||||
TrackChangeID: p.trackChangeID,
|
||||
Seq: p.positionSeq,
|
||||
Playing: p.state == Playing,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -381,6 +469,13 @@ func (p *Player) onPlaybackFinished() {
|
||||
p.state = Stopped
|
||||
handler := p.playbackFinishedHandler
|
||||
mc := p.mediaControls
|
||||
|
||||
// Rewind so the position the UI is told is the truth: a finished
|
||||
// track sits at 0:00, ready to play again, rather than reporting
|
||||
// its own length forever. Play() rebuilds the streamer chain from
|
||||
// the Stopped state anyway, so this only moves the decoder.
|
||||
p.rewindLocked()
|
||||
p.emitPositionLocked()
|
||||
p.mu.Unlock()
|
||||
|
||||
// Emit Wails events outside the lock — these are non-blocking
|
||||
@@ -476,6 +571,7 @@ func (p *Player) loadFileLocked(filePath string) error {
|
||||
p.startPaused()
|
||||
p.emitPlaybackStateChanged(p.state)
|
||||
p.emitTrackChanged()
|
||||
p.emitPositionLocked()
|
||||
p.saveState()
|
||||
p.logger.Info(
|
||||
"File loaded, state set to paused", "file", filePath,
|
||||
@@ -553,6 +649,7 @@ func (p *Player) Play() error {
|
||||
|
||||
p.state = Playing
|
||||
p.emitPlaybackStateChanged(p.state)
|
||||
p.emitPositionLocked()
|
||||
p.logger.Info("Started playback")
|
||||
|
||||
return nil
|
||||
@@ -581,6 +678,7 @@ func (p *Player) Pause() error {
|
||||
p.state = Paused
|
||||
p.logger.Info("Paused playback")
|
||||
p.emitPlaybackStateChanged(p.state)
|
||||
p.emitPositionLocked()
|
||||
p.saveState()
|
||||
} else {
|
||||
p.logger.Info("Already paused or not playing")
|
||||
@@ -771,6 +869,29 @@ func (p *Player) Seek(targetSeconds int) error {
|
||||
return p.seekLocked(targetSeconds)
|
||||
}
|
||||
|
||||
// rewindLocked returns the decoder to the start of the track without
|
||||
// touching playback state. Must be called with p.mu held.
|
||||
func (p *Player) rewindLocked() {
|
||||
if p.seeker == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Same source lock the seek path takes: the read-ahead goroutine
|
||||
// must not be inside Read while the decoder seeks.
|
||||
if p.buffered != nil {
|
||||
p.buffered.LockSource()
|
||||
defer p.buffered.UnlockSource()
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
err := p.seeker.Seek(0)
|
||||
speaker.Unlock()
|
||||
|
||||
if err != nil {
|
||||
p.logger.Warn("Failed to rewind finished track", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Player) seekLocked(targetSeconds int) error {
|
||||
if p.seeker == nil {
|
||||
events.Emit(p.ctx, events.SeekFailed)
|
||||
@@ -846,6 +967,11 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
"err", seekErr,
|
||||
)
|
||||
|
||||
// The optimistic move the UI already made has to be taken
|
||||
// back, and only the backend knows it did not happen.
|
||||
events.Emit(p.ctx, events.SeekFailed)
|
||||
p.emitPositionLocked()
|
||||
|
||||
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||
}
|
||||
|
||||
@@ -862,6 +988,11 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
p.mediaControls.NotifySeek(targetSeconds)
|
||||
}
|
||||
|
||||
// Report the landing position immediately rather than leaving the
|
||||
// UI to guess until the next tick — this is the half of H-3 that
|
||||
// desynced the seek bar by 30 s over four keyboard seeks.
|
||||
p.emitPositionLocked()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2565,35 +2565,6 @@ func (s *Service) CreateSmartPlaylist(
|
||||
)
|
||||
}
|
||||
|
||||
// SAFETY: Hand-crafted INSERT for smart playlist with
|
||||
// is_smart and smart_rules columns not yet in sqlc schema.
|
||||
// All values are parameterized.
|
||||
rows, err := s.db.QueryContext(
|
||||
`INSERT INTO playlists (name, is_smart, smart_rules)
|
||||
VALUES (?, 1, ?)
|
||||
RETURNING id, name, created_at, updated_at`,
|
||||
trimmed, rulesJSON,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to create smart playlist",
|
||||
"name", trimmed, "err", err,
|
||||
)
|
||||
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
_ = rows.Close()
|
||||
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w",
|
||||
errNoRowReturned,
|
||||
)
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
retName string
|
||||
@@ -2601,25 +2572,38 @@ func (s *Service) CreateSmartPlaylist(
|
||||
updatedAt string
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&id, &retName, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
_ = rows.Close()
|
||||
|
||||
// SAFETY: Hand-crafted INSERT for smart playlist with
|
||||
// is_smart and smart_rules columns not yet in sqlc schema.
|
||||
// All values are parameterized.
|
||||
//
|
||||
// QueryRowWriter, not QueryContext: this is an INSERT wearing a
|
||||
// query's shape, and QueryContext routes to the query-only read
|
||||
// pool. Through that handle it failed with "attempt to write a
|
||||
// readonly database", i.e. no smart playlist could be created at
|
||||
// all. A RETURNING clause does not make a write a read.
|
||||
if err := s.db.QueryRowWriter(
|
||||
`INSERT INTO playlists (name, is_smart, smart_rules)
|
||||
VALUES (?, 1, ?)
|
||||
RETURNING id, name, created_at, updated_at`,
|
||||
trimmed, rulesJSON,
|
||||
).Scan(&id, &retName, &createdAt, &updatedAt); err != nil {
|
||||
s.logger.Error(
|
||||
"Failed to create smart playlist",
|
||||
"name", trimmed, "err", err,
|
||||
)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w",
|
||||
errNoRowReturned,
|
||||
)
|
||||
}
|
||||
|
||||
return Summary{}, fmt.Errorf(
|
||||
"failed to create smart playlist: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Close before RefreshSmartPlaylist issues its own queries
|
||||
// (MaxOpenConns=1 test DBs would deadlock).
|
||||
_ = rows.Close()
|
||||
|
||||
s.logger.Info(
|
||||
"Smart playlist created",
|
||||
"id", id, "name", retName,
|
||||
|
||||
@@ -43,6 +43,28 @@ func (q *Queue) emitModeChanged() {
|
||||
)
|
||||
}
|
||||
|
||||
// emitPlaybackFailed tells the frontend that a track could not be
|
||||
// played. Before this existed the failure was logged, the index was
|
||||
// reverted and nothing reached the user: a moved file was a button
|
||||
// that did nothing, twice, forever (errors.C1).
|
||||
func (q *Queue) emitPlaybackFailed(track Track, reason error) {
|
||||
msg := ""
|
||||
if reason != nil {
|
||||
msg = reason.Error()
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
q.ctx,
|
||||
events.PlaybackFailed,
|
||||
PlaybackFailure{
|
||||
FilePath: track.FilePath,
|
||||
Title: track.Title,
|
||||
Artist: track.Artist,
|
||||
Reason: msg,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// emitTracksModified emits a delta update for track list changes.
|
||||
func (q *Queue) emitTracksModified(
|
||||
action string,
|
||||
|
||||
@@ -29,18 +29,20 @@ func (q *Queue) OnPlaybackFinished() {
|
||||
nextIdx := q.nextIndex()
|
||||
if nextIdx == -1 {
|
||||
// Queue exhausted — this is the extension point for a future fallback playlist.
|
||||
q.onQueueExhausted()
|
||||
q.onQueueExhausted(false)
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prevIndex := q.currentIndex
|
||||
q.currentIndex = nextIdx
|
||||
|
||||
if !q.playCurrentTrack() {
|
||||
q.currentIndex = prevIndex
|
||||
// Skip over tracks that cannot be played instead of reverting.
|
||||
// Reverting stopped playback dead on the first moved file and left
|
||||
// Next unable to get past it, since Next hit the same track.
|
||||
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
||||
q.onQueueExhausted(false)
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
var errFileMissing = errors.New("no such file or directory")
|
||||
|
||||
// failingLoader is a TrackLoader that refuses to load a named set of
|
||||
// paths — a moved file, in other words, which is the whole of
|
||||
// errors.C1.
|
||||
type failingLoader struct {
|
||||
mockTrackLoader
|
||||
fails map[string]bool
|
||||
unloaded int
|
||||
}
|
||||
|
||||
func (f *failingLoader) LoadFile(filePath string) error {
|
||||
if f.fails[filePath] {
|
||||
return errFileMissing
|
||||
}
|
||||
|
||||
return f.mockTrackLoader.LoadFile(filePath)
|
||||
}
|
||||
|
||||
func (f *failingLoader) UnloadTrack() { f.unloaded++ }
|
||||
|
||||
// setupFailingQueue is setupRecordedQueue with a loader that fails on
|
||||
// the given paths.
|
||||
func setupFailingQueue(
|
||||
t *testing.T,
|
||||
) (*Queue, *database.DB, *events.Recorder, *failingLoader) {
|
||||
t.Helper()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
q := NewQueue(slog.Default(), db)
|
||||
loader := &failingLoader{fails: map[string]bool{}}
|
||||
q.SetPlayer(loader)
|
||||
|
||||
rec := events.NewRecorder()
|
||||
q.SetContext(events.WithSink(context.Background(), rec))
|
||||
|
||||
return q, db, rec, loader
|
||||
}
|
||||
|
||||
// failureOf returns the payload of the most recent PlaybackFailed.
|
||||
func failureOf(t *testing.T, rec *events.Recorder) PlaybackFailure {
|
||||
t.Helper()
|
||||
|
||||
ev, ok := rec.Last(events.PlaybackFailed)
|
||||
if !ok {
|
||||
t.Fatalf("no PlaybackFailed emitted; got %v", rec.Names())
|
||||
}
|
||||
|
||||
failure, ok := ev.Payload().(PlaybackFailure)
|
||||
if !ok {
|
||||
t.Fatalf(
|
||||
"PlaybackFailed payload is %T, want queue.PlaybackFailure",
|
||||
ev.Payload(),
|
||||
)
|
||||
}
|
||||
|
||||
return failure
|
||||
}
|
||||
|
||||
func TestPlaybackFailed_EmittedForAMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 3)
|
||||
loader.fails[paths[1]] = true
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.PlayIndex(1)
|
||||
|
||||
failure := failureOf(t, rec)
|
||||
if failure.FilePath != paths[1] {
|
||||
t.Errorf("filePath: got %q, want %q", failure.FilePath, paths[1])
|
||||
}
|
||||
|
||||
if failure.Reason == "" {
|
||||
t.Error("reason is empty; the frontend has nothing to log")
|
||||
}
|
||||
|
||||
if failure.Title == "" {
|
||||
t.Error("title is empty; a message cannot name the track")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 3)
|
||||
loader.fails[paths[1]] = true
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.Play()
|
||||
|
||||
// The first track finished: auto-advance lands on the missing
|
||||
// file and must step over it rather than stopping dead.
|
||||
q.OnPlaybackFinished()
|
||||
|
||||
if got := q.GetState().CurrentIndex; got != 2 {
|
||||
t.Errorf("currentIndex after skipping: got %d, want 2", got)
|
||||
}
|
||||
|
||||
if _, ok := rec.Last(events.PlaybackFailed); !ok {
|
||||
t.Errorf("skipped silently; events were %v", rec.Names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackFailed_NextSkipsPastIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, _, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 4)
|
||||
loader.fails[paths[1]] = true
|
||||
loader.fails[paths[2]] = true
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.Next()
|
||||
|
||||
if got := q.GetState().CurrentIndex; got != 3 {
|
||||
t.Errorf(
|
||||
"currentIndex after two unplayable tracks: got %d, want 3",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackFailed_WholeQueueUnplayableStopsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 3)
|
||||
|
||||
for _, p := range paths {
|
||||
loader.fails[p] = true
|
||||
}
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.repeatMode = RepeatAll
|
||||
|
||||
rec.Reset()
|
||||
q.Next()
|
||||
|
||||
// Every file is gone (a disconnected drive). One pass, then
|
||||
// stop — not an endless wrap around a RepeatAll queue.
|
||||
if got := q.GetState().CurrentIndex; got != -1 {
|
||||
t.Errorf("currentIndex: got %d, want -1 (exhausted)", got)
|
||||
}
|
||||
|
||||
if got := rec.Count(events.PlaybackFailed); got != len(paths) {
|
||||
t.Errorf(
|
||||
"PlaybackFailed count: got %d, want %d (one pass)",
|
||||
got, len(paths),
|
||||
)
|
||||
}
|
||||
|
||||
if loader.unloaded != 0 {
|
||||
t.Errorf(
|
||||
"player unloaded %d times; the finished track should stay "+
|
||||
"on the now-playing bar",
|
||||
loader.unloaded,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, _, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 1)
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.Play()
|
||||
q.OnPlaybackFinished()
|
||||
|
||||
if q.GetState().CurrentIndex != -1 {
|
||||
t.Errorf(
|
||||
"currentIndex: got %d, want -1",
|
||||
q.GetState().CurrentIndex,
|
||||
)
|
||||
}
|
||||
|
||||
// H-18: the bar used to blank while the queue panel still listed
|
||||
// what had just played.
|
||||
if loader.unloaded != 0 {
|
||||
t.Errorf("player unloaded %d times, want 0", loader.unloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueExhausted_UnloadsWhenTheTrackIsRemoved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, _, loader := setupFailingQueue(t)
|
||||
paths := seedAudioFiles(t, db, 1)
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
q.RemoveTrack(0)
|
||||
|
||||
// Nothing left to show, so the bar clears.
|
||||
if loader.unloaded == 0 {
|
||||
t.Error("player not unloaded after its track left the queue")
|
||||
}
|
||||
}
|
||||
@@ -36,14 +36,25 @@ func (q *Queue) recordPlay(audioFileID int64) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update denormalized columns on audio_files.
|
||||
_, err = q.db.ExecContext(
|
||||
// Update the denormalized columns on audio_files, and read back what
|
||||
// they became in the same statement: the event below has to carry the
|
||||
// new values, and a follow-up SELECT could race another play.
|
||||
//
|
||||
// RETURNING requires the writer connection — the read pool is a
|
||||
// separate sql.DB, and this is a write.
|
||||
var (
|
||||
playCount int64
|
||||
filePath string
|
||||
)
|
||||
|
||||
err = q.db.QueryRowWriter(
|
||||
`UPDATE audio_files
|
||||
SET play_count = play_count + 1,
|
||||
last_played = ?
|
||||
WHERE id = ?`,
|
||||
WHERE id = ?
|
||||
RETURNING play_count, file_path`,
|
||||
now, audioFileID,
|
||||
)
|
||||
).Scan(&playCount, &filePath)
|
||||
if err != nil {
|
||||
q.logger.Error(
|
||||
"failed to update play count",
|
||||
@@ -57,8 +68,22 @@ func (q *Queue) recordPlay(audioFileID int64) {
|
||||
q.logger.Info(
|
||||
"Play recorded",
|
||||
"audioFileId", audioFileID,
|
||||
"playCount", playCount,
|
||||
)
|
||||
|
||||
// Notify frontend so the track list refreshes play count.
|
||||
events.Emit(q.ctx, events.TrackMetadataChanged)
|
||||
// Deliberately NOT TrackMetadataChanged. That event means "the tags
|
||||
// on disk were rewritten", which can change an album, an artist or a
|
||||
// genre, so the frontend answers it by discarding its whole library
|
||||
// cache and refetching — measured at ~37 MB across the IPC and ~0.8 s
|
||||
// of blocked main thread, once per finished song, plus clearing
|
||||
// whatever the user had selected in the track list (perf.C1/C2).
|
||||
//
|
||||
// A play count is one integer on one row, and this payload carries
|
||||
// enough for the frontend to patch it in place.
|
||||
events.Emit(q.ctx, events.TrackPlayCountChanged, map[string]any{
|
||||
"audioFileId": audioFileID,
|
||||
"filePath": filePath,
|
||||
"playCount": playCount,
|
||||
"lastPlayed": now,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// A finished track used to emit TrackMetadataChanged — the event that
|
||||
// means "the tags on disk were rewritten" — which the frontend answers
|
||||
// by discarding its entire library cache and refetching tracks, albums,
|
||||
// artists and genres. Measured on a 50 000-track library that is
|
||||
// ~37 MB across the IPC and ~0.8 s of blocked main thread per song, and
|
||||
// it cleared the user's track selection every time (audit perf.C1/C2).
|
||||
//
|
||||
// So the assertion that matters is not only that the new event fires:
|
||||
// it is that the old one *stops*. A change that added
|
||||
// TrackPlayCountChanged and left TrackMetadataChanged in place would
|
||||
// look right in the payload and fix nothing.
|
||||
func TestRecordPlay_EmitsPlayCountNotMetadataChanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec := setupRecordedQueue(t)
|
||||
paths := seedAudioFiles(t, db, 2)
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
rec.Reset()
|
||||
|
||||
q.recordPlay(1)
|
||||
|
||||
if n := rec.Count(events.TrackMetadataChanged); n != 0 {
|
||||
t.Errorf(
|
||||
"recording a play emitted TrackMetadataChanged %d time(s); "+
|
||||
"that event invalidates the whole library cache",
|
||||
n,
|
||||
)
|
||||
}
|
||||
|
||||
ev, ok := rec.Last(events.TrackPlayCountChanged)
|
||||
if !ok {
|
||||
t.Fatalf(
|
||||
"no TrackPlayCountChanged emitted; got %v", rec.Names(),
|
||||
)
|
||||
}
|
||||
|
||||
payload, ok := ev.Payload().(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want map[string]any", ev.Payload())
|
||||
}
|
||||
|
||||
// The point of the payload is that it is enough to patch one track
|
||||
// in place, so a consumer never needs to refetch anything. A
|
||||
// missing field here means a consumer has to.
|
||||
for _, key := range []string{
|
||||
"audioFileId", "filePath", "playCount", "lastPlayed",
|
||||
} {
|
||||
if _, ok := payload[key]; !ok {
|
||||
t.Errorf("payload is missing %q: %v", key, payload)
|
||||
}
|
||||
}
|
||||
|
||||
if got := payload["filePath"]; got != paths[0] {
|
||||
t.Errorf("filePath: got %v, want %v", got, paths[0])
|
||||
}
|
||||
|
||||
if got, want := payload["playCount"], int64(1); got != want {
|
||||
t.Errorf("playCount: got %v (%T), want %v", got, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The count comes from the database rather than from a counter the
|
||||
// event handler keeps, so two plays report 1 then 2 — and a frontend
|
||||
// that renders the payload cannot drift from the stored value.
|
||||
func TestRecordPlay_ReportsTheStoredCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec := setupRecordedQueue(t)
|
||||
paths := seedAudioFiles(t, db, 1)
|
||||
|
||||
q.SetQueue(paths, 0, false)
|
||||
rec.Reset()
|
||||
|
||||
q.recordPlay(1)
|
||||
q.recordPlay(1)
|
||||
|
||||
got := make([]any, 0, 2)
|
||||
|
||||
for _, ev := range rec.Named(events.TrackPlayCountChanged) {
|
||||
payload, ok := ev.Payload().(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want map[string]any", ev.Payload())
|
||||
}
|
||||
|
||||
got = append(got, payload["playCount"])
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d events, want 2", len(got))
|
||||
}
|
||||
|
||||
if got[0] != int64(1) || got[1] != int64(2) {
|
||||
t.Errorf("play counts: got %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,16 @@ type ModeChanged struct {
|
||||
RepeatMode RepeatMode `json:"repeatMode"`
|
||||
}
|
||||
|
||||
// PlaybackFailure is the payload for the PlaybackFailed event. It
|
||||
// carries enough to name the track in a message without the frontend
|
||||
// having to look anything up, and the raw reason for the log.
|
||||
type PlaybackFailure struct {
|
||||
FilePath string `json:"filePath"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// TracksModified is the payload for the QueueTracksModified event.
|
||||
type TracksModified struct {
|
||||
Action string `json:"action"`
|
||||
@@ -910,16 +920,15 @@ func (q *Queue) Next() {
|
||||
|
||||
nextIdx := q.nextIndex()
|
||||
if nextIdx == -1 {
|
||||
q.onQueueExhausted()
|
||||
q.onQueueExhausted(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prevIndex := q.currentIndex
|
||||
q.currentIndex = nextIdx
|
||||
|
||||
if !q.playOrLoadCurrentTrack(wasPlaying) {
|
||||
q.currentIndex = prevIndex
|
||||
if !q.playCurrentOrSkip(wasPlaying, q.nextIndex) {
|
||||
q.onQueueExhausted(false)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -974,7 +983,9 @@ func (q *Queue) Previous() {
|
||||
prevCurrentIndex := q.currentIndex
|
||||
q.currentIndex = prevIdx
|
||||
|
||||
if !q.playOrLoadCurrentTrack(wasPlaying) {
|
||||
// Backwards for the same reason Next skips forwards: otherwise a
|
||||
// bad file behind you makes Previous a button that does nothing.
|
||||
if !q.playCurrentOrSkip(wasPlaying, q.previousIndex) {
|
||||
q.currentIndex = prevCurrentIndex
|
||||
|
||||
return
|
||||
@@ -1041,7 +1052,7 @@ func (q *Queue) playFromStart() {
|
||||
q.currentIndex = 0
|
||||
}
|
||||
|
||||
if !q.playCurrentTrack() {
|
||||
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
||||
q.currentIndex = -1
|
||||
|
||||
return
|
||||
@@ -1208,6 +1219,7 @@ func (q *Queue) loadCurrentTrack() bool {
|
||||
"Failed to load file from queue",
|
||||
"filePath", track.FilePath, "err", err,
|
||||
)
|
||||
q.emitPlaybackFailed(track, err)
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1231,6 +1243,7 @@ func (q *Queue) playCurrentTrack() bool {
|
||||
"Failed to play file from queue",
|
||||
"filePath", track.FilePath, "err", err,
|
||||
)
|
||||
q.emitPlaybackFailed(track, err)
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1238,12 +1251,39 @@ func (q *Queue) playCurrentTrack() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// playCurrentOrSkip plays (or loads) the track at currentIndex, and on
|
||||
// failure steps to the next candidate rather than giving up. A moved
|
||||
// or unreadable file in the middle of a queue used to stop playback
|
||||
// dead, and Next did not help because it hit the same track and
|
||||
// reverted.
|
||||
//
|
||||
// The attempt count is bounded by the queue length so a queue whose
|
||||
// files have all gone (a disconnected drive) stops after one pass
|
||||
// instead of spinning forever through a RepeatAll wrap. Returns false
|
||||
// when nothing reachable can be played.
|
||||
func (q *Queue) playCurrentOrSkip(autoPlay bool, step func() int) bool {
|
||||
for range len(q.tracks) {
|
||||
if q.playOrLoadCurrentTrack(autoPlay) {
|
||||
return true
|
||||
}
|
||||
|
||||
next := step()
|
||||
if next == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
q.currentIndex = next
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// handleCurrentTrackRemoved handles the case where the currently loaded
|
||||
// track was removed from the queue. If tracks remain it loads the track
|
||||
// now at currentIndex (paused); otherwise it exhausts the queue.
|
||||
func (q *Queue) handleCurrentTrackRemoved() {
|
||||
if len(q.tracks) == 0 {
|
||||
q.onQueueExhausted()
|
||||
q.onQueueExhausted(true)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -1252,14 +1292,21 @@ func (q *Queue) handleCurrentTrackRemoved() {
|
||||
}
|
||||
|
||||
// onQueueExhausted is called when there are no more tracks to play.
|
||||
// It unloads the current track, resets the index to -1 (no current track),
|
||||
// and notifies the frontend.
|
||||
func (q *Queue) onQueueExhausted() {
|
||||
q.logger.Info("Queue exhausted, unloading track")
|
||||
// It resets the index to -1 (no current track) and notifies the
|
||||
// frontend.
|
||||
//
|
||||
// unload says whether the player should also let go of the file it
|
||||
// holds. When the queue simply ran out, it should not: the finished
|
||||
// track stays on the now-playing bar, paused at 0:00, rather than the
|
||||
// bar blanking while the queue panel still lists what just played
|
||||
// (H-18). When the current track was removed from the queue, or the
|
||||
// queue was cleared, there is nothing left to show and it does.
|
||||
func (q *Queue) onQueueExhausted(unload bool) {
|
||||
q.logger.Info("Queue exhausted", "unload", unload)
|
||||
|
||||
q.currentIndex = -1
|
||||
|
||||
if q.player != nil {
|
||||
if unload && q.player != nil {
|
||||
q.player.UnloadTrack()
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ func DefaultBindings() map[string]string {
|
||||
// Panel-specific (track list)
|
||||
"tracklist.play": "Enter",
|
||||
"tracklist.delete": "Delete",
|
||||
|
||||
// Panel-specific (autotag review). These are the keys the
|
||||
// autotag page used to bind on its own document listener, which
|
||||
// fired from every other page and could not arbitrate with the
|
||||
// global bindings above. As panel bindings they apply only
|
||||
// while that page is the one on screen.
|
||||
"autotag.apply": "A",
|
||||
"autotag.skip": "S",
|
||||
"autotag.leave": "L",
|
||||
"autotag.paste": "U",
|
||||
"autotag.search": "F",
|
||||
"autotag.next": "Down",
|
||||
"autotag.previous": "Up",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,19 @@ func requireFFmpeg() error {
|
||||
// the library's cover-art deduplication is supposed to collapse into a
|
||||
// single stored blob.
|
||||
func coverJPEG(key string) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, coverSizePx, coverSizePx))
|
||||
return coverJPEGSized(key, coverSizePx)
|
||||
}
|
||||
|
||||
// coverJPEGSized is coverJPEG at an explicit edge length. The bulk
|
||||
// library uses a larger one, because a 64 px cover cannot show the
|
||||
// difference between rendering the original artwork and rendering the
|
||||
// thumbnail tier that exists for the purpose.
|
||||
// coverJPEGSized is coverJPEG at an explicit edge length. The bulk
|
||||
// library uses a larger one, because a 64 px cover cannot show the
|
||||
// difference between rendering the original artwork and rendering the
|
||||
// thumbnail tier that exists for the purpose.
|
||||
func coverJPEGSized(key string, px int) ([]byte, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, px, px))
|
||||
|
||||
// A per-key hue derived from the key's bytes, plus a diagonal
|
||||
// band, so covers are distinguishable by eye in a screenshot.
|
||||
@@ -177,8 +189,8 @@ func coverJPEG(key string) ([]byte, error) {
|
||||
A: 255,
|
||||
}
|
||||
|
||||
for y := range coverSizePx {
|
||||
for x := range coverSizePx {
|
||||
for y := range px {
|
||||
for x := range px {
|
||||
c := base
|
||||
if (x+y)%16 < 8 {
|
||||
c.R /= 2
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package main
|
||||
|
||||
// The bulk library exists for one purpose: measuring. Plan 007
|
||||
// phase 4 is verified by numbers rather than assertions, and every
|
||||
// number in `.planning/audits/2026-08-11-ui/perf.md` is quoted "at
|
||||
// 50 000 tracks" — a size the deterministic fixture library
|
||||
// (`libraryFixtures`, a few dozen files) cannot reach and should not
|
||||
// try to. The two are generated by the same command because they are
|
||||
// the same problem at two scales, but they share nothing else: the
|
||||
// fixture library is a curated set of *cases* selected by name, and
|
||||
// this one is a shapeless pile whose only interesting property is how
|
||||
// big it is.
|
||||
//
|
||||
// It is deliberately not committed and deliberately not a dependency
|
||||
// of any test. `make test` must not take four minutes because someone
|
||||
// wanted a scroll trace.
|
||||
//
|
||||
// Generation avoids ffmpeg per file. Encoding 50 000 files one
|
||||
// process at a time is ~40 minutes; encoding a handful of source
|
||||
// clips once and copying them is ~20 seconds, and the audio content is
|
||||
// not what is being measured. Tags still go through backend/tagwriter
|
||||
// for the same reason the fixture library does — a library the app
|
||||
// cannot read back tells you nothing about the app.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// Shape of the generated library. These ratios are roughly what a
|
||||
// real 50 k-track collection looks like, and they matter: several of
|
||||
// the findings being measured are O(albums) or O(artists) rather than
|
||||
// O(tracks), so a library of 50 000 tracks in one album would measure
|
||||
// nothing.
|
||||
const (
|
||||
bulkTracksPerAlbum = 10
|
||||
bulkAlbumsPerArtist = 4
|
||||
bulkSourceVariants = 6
|
||||
bulkCoverPx = 300
|
||||
bulkYearBase = 1968
|
||||
bulkYearSpan = 56
|
||||
)
|
||||
|
||||
// bulkGenres is small on purpose — a real library has a long tail of
|
||||
// genres but a short head, and the head is what the genres view and
|
||||
// the home shelves actually work over.
|
||||
//
|
||||
//nolint:gochecknoglobals // a package-level constant table.
|
||||
var bulkGenres = []string{
|
||||
"Ambient", "Post-Rock", "Dream Pop", "Shoegaze", "Krautrock",
|
||||
"Minimal Techno", "Free Jazz", "Bossa Nova", "Baroque", "Drone",
|
||||
"Noise Rock", "Slowcore", "Trip Hop", "Dub", "Highlife",
|
||||
"Cumbia", "Fado", "Gamelan", "Chamber Pop", "Math Rock",
|
||||
"Field Recording", "Musique Concrète", "Sludge", "Zeuhl",
|
||||
}
|
||||
|
||||
// Word tables composing artist and album names. Names are generated
|
||||
// rather than numbered so search ranking has something with shared
|
||||
// prefixes, shared words and varying lengths to rank — `Artist 04213`
|
||||
// would make every query either match everything or nothing.
|
||||
//
|
||||
//nolint:gochecknoglobals // package-level constant tables.
|
||||
var (
|
||||
bulkAdjectives = []string{
|
||||
"Hollow", "Northern", "Quiet", "Endless", "Amber", "Glass",
|
||||
"Distant", "Velvet", "Iron", "Pale", "Golden", "Silent",
|
||||
"Crimson", "Winter", "Coastal", "Electric", "Marble", "Slow",
|
||||
}
|
||||
bulkNouns = []string{
|
||||
"Harbour", "Tideline", "Cartography", "Signal", "Meridian",
|
||||
"Aviary", "Lantern", "Orchard", "Pavilion", "Estuary",
|
||||
"Sequence", "Almanac", "Corridor", "Foundry", "Cascade",
|
||||
"Interval", "Radiance", "Threshold", "Migration", "Beacon",
|
||||
}
|
||||
bulkCollectives = []string{
|
||||
"Ensemble", "Trio", "Quartet", "Society", "Orchestra",
|
||||
"Collective", "Choir", "Union", "Company", "Band",
|
||||
}
|
||||
)
|
||||
|
||||
// bulkSpec is a fully-resolved request for a bulk library.
|
||||
type bulkSpec struct {
|
||||
Out string
|
||||
Tracks int
|
||||
CoverPx int
|
||||
}
|
||||
|
||||
// hash identifies the spec, so regeneration can be skipped the same
|
||||
// way the fixture library skips it.
|
||||
//
|
||||
// The leading version string covers everything about *generation* that
|
||||
// the parameters do not — the naming tables, the cover renderer, the
|
||||
// source-clip durations. Bump it when any of those change, or a stale
|
||||
// library on disk will be silently accepted as current.
|
||||
func (s bulkSpec) hash() string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf(
|
||||
"bulk/v2|%d|%d|%d|%d|%d",
|
||||
s.Tracks, s.CoverPx,
|
||||
bulkTracksPerAlbum, bulkAlbumsPerArtist, bulkSourceVariants,
|
||||
)))
|
||||
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// bulkTrack is one generated file, resolved from its index alone.
|
||||
// Everything is a pure function of the index, so generation
|
||||
// parallelises without coordination and two runs agree exactly.
|
||||
type bulkTrack struct {
|
||||
Rel string
|
||||
Variant int
|
||||
Album int
|
||||
Tags tags
|
||||
}
|
||||
|
||||
// bulkTrackAt derives track i's identity.
|
||||
func bulkTrackAt(i int) bulkTrack {
|
||||
album := i / bulkTracksPerAlbum
|
||||
artist := album / bulkAlbumsPerArtist
|
||||
trackNo := i%bulkTracksPerAlbum + 1
|
||||
|
||||
artistName := bulkArtistName(artist)
|
||||
albumName := bulkAlbumName(album)
|
||||
title := bulkTitle(i)
|
||||
|
||||
return bulkTrack{
|
||||
Rel: filepath.Join(
|
||||
sanitizePathSegment(artistName),
|
||||
sanitizePathSegment(albumName),
|
||||
fmt.Sprintf("%02d %s.mp3", trackNo, sanitizePathSegment(title)),
|
||||
),
|
||||
Variant: i % bulkSourceVariants,
|
||||
Album: album,
|
||||
Tags: tags{
|
||||
Title: title,
|
||||
Artist: artistName,
|
||||
Album: albumName,
|
||||
AlbumArtist: artistName,
|
||||
Genre: bulkGenres[album%len(bulkGenres)],
|
||||
Year: bulkYearBase + album%bulkYearSpan,
|
||||
TrackNumber: trackNo,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bulkArtistName(n int) string {
|
||||
switch n % 3 {
|
||||
case 0:
|
||||
return bulkAdjectives[n%len(bulkAdjectives)] + " " +
|
||||
bulkNouns[(n/len(bulkAdjectives))%len(bulkNouns)]
|
||||
case 1:
|
||||
return "The " + bulkNouns[n%len(bulkNouns)] + " " +
|
||||
bulkCollectives[(n/len(bulkNouns))%len(bulkCollectives)]
|
||||
default:
|
||||
return bulkNouns[n%len(bulkNouns)] + " & the " +
|
||||
bulkAdjectives[(n/len(bulkNouns))%len(bulkAdjectives)] + "s"
|
||||
}
|
||||
}
|
||||
|
||||
func bulkAlbumName(n int) string {
|
||||
return bulkAdjectives[(n*7)%len(bulkAdjectives)] + " " +
|
||||
bulkNouns[(n*13)%len(bulkNouns)] +
|
||||
" " + strconv.Itoa(n%97)
|
||||
}
|
||||
|
||||
func bulkTitle(n int) string {
|
||||
return bulkNouns[(n*3)%len(bulkNouns)] + " " +
|
||||
bulkAdjectives[(n*11)%len(bulkAdjectives)] +
|
||||
" " + strconv.Itoa(n%211)
|
||||
}
|
||||
|
||||
// sanitizePathSegment keeps generated names usable as path components
|
||||
// on every platform the app builds for.
|
||||
func sanitizePathSegment(s string) string {
|
||||
const bad = `/\:*?"<>|`
|
||||
|
||||
out := []rune(s)
|
||||
for i, r := range out {
|
||||
for _, b := range bad {
|
||||
if r == b {
|
||||
out[i] = '_'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// generateBulk builds (or skips) a bulk library.
|
||||
func generateBulk(spec bulkSpec, force bool) error {
|
||||
manifestPath := spec.Out + ".manifest.json"
|
||||
|
||||
if !force && readManifestHash(manifestPath) == spec.hash() {
|
||||
if _, err := os.Stat(spec.Out); err == nil {
|
||||
fmt.Printf(
|
||||
"bulk: up to date (%d tracks, hash %s)\n",
|
||||
spec.Tracks, spec.hash()[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := requireFFmpeg(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(spec.Out); err != nil {
|
||||
return fmt.Errorf("clean %s: %w", spec.Out, err)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
|
||||
sources, cleanup, err := bulkSources(spec.Out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
covers, err := bulkCovers(spec, sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := bulkWrite(spec, sources, covers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeManifest(manifestPath, &manifest{
|
||||
Version: manifestVersion,
|
||||
Generator: "gentestdata -bulk",
|
||||
Hash: spec.hash(),
|
||||
LibraryRoot: filepath.ToSlash(spec.Out),
|
||||
TrackCount: spec.Tracks,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"bulk: generated %d tracks in %s (%s, hash %s)\n",
|
||||
spec.Tracks, spec.Out,
|
||||
time.Since(started).Round(time.Second),
|
||||
spec.hash()[:12],
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bulkSources encodes the handful of distinct clips every generated
|
||||
// file is copied from. Durations vary so "time between tracks" is not
|
||||
// measuring one number repeatedly, and stay short so a measurement run
|
||||
// that plays through a dozen tracks takes seconds.
|
||||
func bulkSources(out string) ([][]byte, func(), error) {
|
||||
tmp, err := os.MkdirTemp("", "yj-bulk-src-")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("temp dir: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() { _ = os.RemoveAll(tmp) }
|
||||
|
||||
if err := os.MkdirAll(out, dirPerm); err != nil {
|
||||
cleanup()
|
||||
|
||||
return nil, nil, fmt.Errorf("mkdir %s: %w", out, err)
|
||||
}
|
||||
|
||||
sources := make([][]byte, bulkSourceVariants)
|
||||
|
||||
for i := range bulkSourceVariants {
|
||||
wav := filepath.Join(tmp, strconv.Itoa(i)+".wav")
|
||||
mp3 := filepath.Join(tmp, strconv.Itoa(i)+".mp3")
|
||||
dur := time.Duration(2+i%3) * time.Second
|
||||
|
||||
if err := synthesizeWAV(wav, dur, 220+float64(i)*55); err != nil {
|
||||
cleanup()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := transcode(wav, mp3, tagwriter.FormatMP3); err != nil {
|
||||
cleanup()
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(mp3) //nolint:gosec // generated path.
|
||||
if err != nil {
|
||||
cleanup()
|
||||
|
||||
return nil, nil, fmt.Errorf("read source clip: %w", err)
|
||||
}
|
||||
|
||||
sources[i] = raw
|
||||
}
|
||||
|
||||
return sources, cleanup, nil
|
||||
}
|
||||
|
||||
// bulkCovers renders one cover per album up front. Rendering it per
|
||||
// track would be the dominant cost of generation and would defeat the
|
||||
// library's cover-art deduplication, which is part of what a scan at
|
||||
// this size is being measured on.
|
||||
func bulkCovers(spec bulkSpec, _ [][]byte) ([][]byte, error) {
|
||||
albums := (spec.Tracks + bulkTracksPerAlbum - 1) / bulkTracksPerAlbum
|
||||
covers := make([][]byte, albums)
|
||||
|
||||
for i := range albums {
|
||||
img, err := bulkCoverJPEG(bulkAlbumName(i), spec.CoverPx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
covers[i] = img
|
||||
}
|
||||
|
||||
return covers, nil
|
||||
}
|
||||
|
||||
// bulkCoverJPEG renders a bulk album cover.
|
||||
//
|
||||
// Deliberately not coverJPEGSized: the fixture cover's diagonal band
|
||||
// is 37 hard edges at 300 px, which is the worst case for a DCT and
|
||||
// costs ~35 kB per album — 2 GB across a 50 k-track library, most of
|
||||
// it JPEG artefacts around a pattern nobody looks at. A smooth
|
||||
// two-axis gradient is ~6 kB, still distinct per album by eye, and
|
||||
// still deterministic per key.
|
||||
func bulkCoverJPEG(key string, px int) ([]byte, error) {
|
||||
var seed uint32
|
||||
for _, b := range []byte(key) {
|
||||
seed = seed*31 + uint32(b)
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, px, px))
|
||||
base := color.RGBA{
|
||||
R: uint8(seed >> 16),
|
||||
G: uint8(seed >> 8),
|
||||
B: uint8(seed),
|
||||
A: 255,
|
||||
}
|
||||
|
||||
for y := range px {
|
||||
shade := float64(y) / float64(px)
|
||||
|
||||
for x := range px {
|
||||
tint := (shade + float64(x)/float64(px)) / 2
|
||||
|
||||
img.Set(x, y, color.RGBA{
|
||||
R: uint8(float64(base.R) * (0.4 + 0.6*tint)),
|
||||
G: uint8(float64(base.G) * (0.4 + 0.6*tint)),
|
||||
B: uint8(float64(base.B) * (0.4 + 0.6*tint)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return nil, fmt.Errorf("encode bulk cover %q: %w", key, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// bulkWrite copies and tags every file, in parallel. tagwriter works
|
||||
// on one path at a time with no shared state, so the only coordination
|
||||
// needed is the error and the progress counter.
|
||||
func bulkWrite(spec bulkSpec, sources, covers [][]byte) error {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelError,
|
||||
}))
|
||||
|
||||
jobs := make(chan int, runtime.NumCPU()*2)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
done atomic.Int64
|
||||
failed atomic.Pointer[error]
|
||||
workers = runtime.NumCPU()
|
||||
)
|
||||
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for i := range jobs {
|
||||
if failed.Load() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := bulkWriteOne(
|
||||
logger, spec, sources, covers, i,
|
||||
); err != nil {
|
||||
failed.CompareAndSwap(nil, &err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if n := done.Add(1); n%5000 == 0 {
|
||||
fmt.Printf("bulk: %d/%d\n", n, spec.Tracks)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := range spec.Tracks {
|
||||
jobs <- i
|
||||
}
|
||||
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
if err := failed.Load(); err != nil {
|
||||
return *err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bulkWriteOne(
|
||||
logger *slog.Logger,
|
||||
spec bulkSpec,
|
||||
sources, covers [][]byte,
|
||||
i int,
|
||||
) error {
|
||||
t := bulkTrackAt(i)
|
||||
dst := filepath.Join(spec.Out, t.Rel)
|
||||
|
||||
if err := ensureDir(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dst, sources[t.Variant], filePerm); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dst, err)
|
||||
}
|
||||
|
||||
changes := t.Tags.changes()
|
||||
changes[tagwriter.FieldCoverArt] = covers[t.Album]
|
||||
|
||||
if err := tagwriter.WriteFileTags(logger, dst, changes); err != nil {
|
||||
return fmt.Errorf("tag %s: %w", t.Rel, err)
|
||||
}
|
||||
|
||||
return stampMTime(dst)
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
//
|
||||
// go run ./cmd/gentestdata # generate if out of date
|
||||
// go run ./cmd/gentestdata -force # regenerate unconditionally
|
||||
// go run ./cmd/gentestdata -bulk 50000 # the measurement library
|
||||
//
|
||||
// The -bulk library is a separate thing with a separate purpose; see
|
||||
// bulk.go. It is not committed, not a test dependency, and generating
|
||||
// it does not regenerate the fixture library.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -44,6 +49,9 @@ func main() {
|
||||
brokenDir string
|
||||
manifestOut string
|
||||
force bool
|
||||
bulkTracks int
|
||||
bulkOut string
|
||||
bulkCover int
|
||||
)
|
||||
|
||||
flag.StringVar(
|
||||
@@ -62,9 +70,33 @@ func main() {
|
||||
&force, "force", false,
|
||||
"regenerate even when the manifest is already up to date",
|
||||
)
|
||||
flag.IntVar(
|
||||
&bulkTracks, "bulk", 0,
|
||||
"generate a bulk measurement library of N tracks instead",
|
||||
)
|
||||
flag.StringVar(
|
||||
&bulkOut, "bulk-out", ".dev/music_library_bulk",
|
||||
"library root for -bulk (gitignored; not a test fixture)",
|
||||
)
|
||||
flag.IntVar(
|
||||
&bulkCover, "bulk-cover-px", bulkCoverPx,
|
||||
"edge length of the embedded cover art for -bulk",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if err := run(outDir, brokenDir, manifestOut, force); err != nil {
|
||||
var err error
|
||||
|
||||
if bulkTracks > 0 {
|
||||
err = generateBulk(bulkSpec{
|
||||
Out: bulkOut,
|
||||
Tracks: bulkTracks,
|
||||
CoverPx: bulkCover,
|
||||
}, force)
|
||||
} else {
|
||||
err = run(outDir, brokenDir, manifestOut, force)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gentestdata:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -34,15 +34,19 @@ type manifestTrack struct {
|
||||
// stamps its own encoder strings, so byte hashes differ between ffmpeg
|
||||
// builds while the fixtures they describe are identical.
|
||||
type manifest struct {
|
||||
Version int `json:"version"`
|
||||
Generator string `json:"generator"`
|
||||
Hash string `json:"hash"`
|
||||
LibraryRoot string `json:"libraryRoot"`
|
||||
BrokenRoot string `json:"brokenRoot"`
|
||||
Cases map[string][]string `json:"cases"`
|
||||
Tracks []manifestTrack `json:"tracks"`
|
||||
Extras []string `json:"extras"`
|
||||
Broken []string `json:"broken"`
|
||||
Version int `json:"version"`
|
||||
Generator string `json:"generator"`
|
||||
Hash string `json:"hash"`
|
||||
LibraryRoot string `json:"libraryRoot"`
|
||||
BrokenRoot string `json:"brokenRoot"`
|
||||
// TrackCount is written by the bulk library, which has tens of
|
||||
// thousands of tracks and no reason to describe each one: nothing
|
||||
// selects a bulk track by name, only the total matters.
|
||||
TrackCount int `json:"trackCount,omitempty"`
|
||||
Cases map[string][]string `json:"cases"`
|
||||
Tracks []manifestTrack `json:"tracks"`
|
||||
Extras []string `json:"extras"`
|
||||
Broken []string `json:"broken"`
|
||||
}
|
||||
|
||||
// buildManifest derives the manifest from the spec alone. It runs
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Plan 007 phase 3: a failure the user caused has to reach the user.
|
||||
*
|
||||
* The failure is induced through `/__test/sql` rather than staged in the
|
||||
* UI: a second library called "Decoy" makes `RenameLibrary` reject with
|
||||
* its duplicate-name error, which is a real binding rejection on a path
|
||||
* the audit found ends at `console.error` (errors.m5).
|
||||
*/
|
||||
test.describe('a failed binding says so', () => {
|
||||
const DECOY = 'Decoy';
|
||||
|
||||
test.afterEach(async ({ testctl }) => {
|
||||
await testctl.sql('DELETE FROM libraries WHERE name = ?', [DECOY]);
|
||||
});
|
||||
|
||||
test('a rejected rename reaches the user, not the console', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
// The library that was seeded by running the app, i.e. the one row
|
||||
// that is not the decoy.
|
||||
const health = await testctl.health();
|
||||
const seeded = health.libraries[0].name as string;
|
||||
|
||||
await testctl.sql(
|
||||
'INSERT INTO libraries (name, path) VALUES (?, ?)',
|
||||
[DECOY, '/tmp/yj-decoy-library'],
|
||||
);
|
||||
|
||||
await app.getByTestId('nav-settings').click();
|
||||
|
||||
const page = app.locator('config-page');
|
||||
|
||||
// Config sections start collapsed.
|
||||
await page
|
||||
.locator('config-section[heading="Libraries"]')
|
||||
.locator('.header')
|
||||
.click();
|
||||
|
||||
// Renaming the decoy to its own name is a no-op the backend
|
||||
// accepts, so the rename has to happen on the other row.
|
||||
const row = page.locator('.library-row').filter({ hasText: seeded });
|
||||
|
||||
// Through the overflow menu, not by clicking the name: the name's
|
||||
// own click bubbles to config-page's document handler, which closes
|
||||
// the editor it just opened.
|
||||
await expect(row).toBeVisible();
|
||||
await row.locator('.overflow-btn').click();
|
||||
await row.getByText('Rename', { exact: true }).click();
|
||||
|
||||
const input = row.locator('.edit-input');
|
||||
|
||||
await input.fill(DECOY);
|
||||
await input.press('Enter');
|
||||
|
||||
// The message is the assertion: a name it can act on, and none of
|
||||
// the Go error's wrapping.
|
||||
const notice = app.getByTestId('notification').first();
|
||||
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toContainText(DECOY);
|
||||
await expect(notice).not.toContainText('could not rename library:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The app must work with no network.
|
||||
*
|
||||
* Every `<wa-icon>` used to resolve to ka-f.fontawesome.com at runtime
|
||||
* (audit H-4 / perf.M9), so a desktop music player offline, on a
|
||||
* captive portal or behind a firewall rendered no icons at all — while
|
||||
* playing files sitting on the local disk. `src/icons/` bundles them
|
||||
* and overrides Web Awesome's `default` library.
|
||||
*
|
||||
* The reproduction is the point. Asserting that a `<wa-icon>` *exists*
|
||||
* would have passed before the fix too: the element is always in the
|
||||
* DOM and only its contents came from the network. So this asserts on
|
||||
* the `<svg>` inside each icon's shadow root, with everything that is
|
||||
* not the app's own origin blocked — which is what a closed network
|
||||
* actually looks like to a local server.
|
||||
*/
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
|
||||
|
||||
/** Icons on the first screen. Fewer than this means nothing rendered. */
|
||||
const EXPECTED_MIN_ICONS = 5;
|
||||
|
||||
const VIEWS = [
|
||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||
'explore', 'downloads', 'jobs', 'settings',
|
||||
];
|
||||
|
||||
type IconState = { name: string; hasSvg: boolean };
|
||||
|
||||
const collectIcons = (): IconState[] => {
|
||||
const icons: IconState[] = [];
|
||||
|
||||
const walk = (root: Document | ShadowRoot): void => {
|
||||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||||
if (el.tagName === 'WA-ICON') {
|
||||
icons.push({
|
||||
name: el.getAttribute('name') ?? '(unnamed)',
|
||||
hasSvg: !!el.shadowRoot?.querySelector('svg'),
|
||||
});
|
||||
}
|
||||
|
||||
if (el.shadowRoot) walk(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
|
||||
walk(document);
|
||||
|
||||
return icons;
|
||||
};
|
||||
|
||||
test.describe('offline', () => {
|
||||
test('icons render with every external request blocked', async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
const blocked: string[] = [];
|
||||
|
||||
// Deliberately not `context.setOffline(true)`: the app *is* a local
|
||||
// server, so taking the whole stack down would break the bindings
|
||||
// rather than the icons. A closed network is precisely "the app's
|
||||
// own origin still answers, nothing else does".
|
||||
await page.route('**/*', (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
if (url.startsWith(baseURL!) || url.startsWith('data:')) {
|
||||
return route.continue();
|
||||
}
|
||||
|
||||
blocked.push(url);
|
||||
|
||||
return route.abort();
|
||||
});
|
||||
|
||||
await page.addInitScript({ path: INIT_SCRIPT });
|
||||
await page.goto(baseURL!);
|
||||
await page.evaluate(() => window.__yjEvents.ready(20_000));
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const drawn = await page.evaluate(collectIcons);
|
||||
|
||||
expect(
|
||||
drawn.length,
|
||||
'no <wa-icon> on screen at all — the app did not render',
|
||||
).toBeGreaterThanOrEqual(EXPECTED_MIN_ICONS);
|
||||
|
||||
expect(
|
||||
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
|
||||
'icons that did not draw with the network closed',
|
||||
).toEqual([]);
|
||||
|
||||
// The complementary half: nothing should have *wanted* the network.
|
||||
// An icon drawing from a warm module cache would satisfy the
|
||||
// assertion above on a machine that happens to be online.
|
||||
expect(
|
||||
blocked.filter((u) => u.includes('fontawesome')),
|
||||
'still reaching for the icon CDN',
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('every icon the app asks for is bundled', async ({ app }) => {
|
||||
for (const view of VIEWS) {
|
||||
await app.evaluate((v) => document.dispatchEvent(
|
||||
new CustomEvent('navigate', { detail: { view: v } }),
|
||||
), view);
|
||||
await app.waitForTimeout(600);
|
||||
}
|
||||
|
||||
const drawn = await app.evaluate(collectIcons);
|
||||
|
||||
expect(drawn.length).toBeGreaterThan(EXPECTED_MIN_ICONS);
|
||||
|
||||
// Covers the icons that only exist on views past the first screen,
|
||||
// which the offline test above never reaches.
|
||||
expect(
|
||||
drawn.filter((i) => !i.hasSvg).map((i) => i.name),
|
||||
'icons that did not draw on some view',
|
||||
).toEqual([]);
|
||||
|
||||
// The resolver records what it could not find rather than failing
|
||||
// silently, because twenty call sites compute their icon name from
|
||||
// state and no static check can enumerate them.
|
||||
const misses = await app.evaluate(
|
||||
() => (window as unknown as { __yjIconMisses?: string[] })
|
||||
.__yjIconMisses ?? [],
|
||||
);
|
||||
|
||||
expect(
|
||||
misses,
|
||||
'add these to frontend/src/icons/names.txt, then run: ' +
|
||||
'node frontend/scripts/fetch-icons.mjs',
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Finishing a track is cheap, and does not disturb the user.
|
||||
*
|
||||
* `recordPlay` used to emit `TrackMetadataChanged` — the event that
|
||||
* means "the tags on disk were rewritten" — so the library store threw
|
||||
* away its whole cache and refetched tracks, albums, artists and genres
|
||||
* once per song. Measured on a 50 000-track library that is ~37 MB
|
||||
* across the IPC and ~0.8 s of blocked main thread *per track*
|
||||
* (audit perf.C1), and `track-list` answered the new array by clearing
|
||||
* the selection, which made selecting forty tracks to drag into a
|
||||
* playlist impossible while music played (perf.C2).
|
||||
*
|
||||
* Both halves are asserted here, and both assertions are negative:
|
||||
* what makes this a fix is the work that no longer happens.
|
||||
*/
|
||||
|
||||
/** Long enough for a fixture track (2–6 s) to finish by itself. */
|
||||
const FINISH_TIMEOUT = 60_000;
|
||||
|
||||
/** Instrument the library bindings so "was anything refetched" is a fact. */
|
||||
const COUNT_LIBRARY_CALLS = `(() => {
|
||||
const w = window;
|
||||
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
|
||||
w.__yjCalls = [];
|
||||
const lib = w.go.library.Library;
|
||||
for (const key of Object.keys(lib)) {
|
||||
const fn = lib[key];
|
||||
if (typeof fn !== 'function' || fn.__counted) continue;
|
||||
const wrapped = function (...args) {
|
||||
w.__yjCalls.push(key);
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
wrapped.__counted = true;
|
||||
lib[key] = wrapped;
|
||||
}
|
||||
})()`;
|
||||
|
||||
/**
|
||||
* Select rows by dispatching on the row rather than clicking it.
|
||||
*
|
||||
* A real click in the middle of a row lands on the track *title*, and
|
||||
* a title navigates — that is `utils/explore-link.ts` working as
|
||||
* designed, not a bug. Which pixel selects a row is not what this spec
|
||||
* is about; that the selection survives a track change is.
|
||||
*/
|
||||
const selectRows = (indices: number[]): void => {
|
||||
const list = document.querySelector('track-list');
|
||||
const rows = Array.from(
|
||||
list?.shadowRoot?.querySelectorAll('[data-testid="track-row"]') ?? [],
|
||||
);
|
||||
|
||||
indices.forEach((i, n) => {
|
||||
rows[i]?.dispatchEvent(new MouseEvent('click', {
|
||||
bubbles: true, composed: true, ctrlKey: n > 0,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
/** The file paths of every row currently showing as selected. */
|
||||
const selectedPaths = (): string[] => Array.from(
|
||||
document.querySelectorAll('track-list'),
|
||||
).flatMap((l) => Array.from(
|
||||
l.shadowRoot?.querySelectorAll('[aria-selected="true"]') ?? [],
|
||||
)).map((r) => r.getAttribute('data-file-path') ?? '');
|
||||
|
||||
/** The first n file paths in the list. */
|
||||
const firstPaths = (n: number): string[] => Array.from(
|
||||
document.querySelector('track-list')
|
||||
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
|
||||
).map((r) => r.getAttribute('data-file-path') ?? '').slice(0, n);
|
||||
|
||||
test.describe('a finished track', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
await expect(app.getByTestId('track-row').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('reports a play count, not a metadata change', async ({ app }) => {
|
||||
const paths = await app.evaluate(firstPaths, 2);
|
||||
|
||||
expect(paths.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
||||
await app.evaluate(COUNT_LIBRARY_CALLS);
|
||||
await resetEvents(app);
|
||||
|
||||
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
||||
|
||||
const event = await app.evaluate(
|
||||
(ms) => window.__yjEvents.wait('TrackPlayCountChanged', {
|
||||
timeoutMs: ms,
|
||||
}),
|
||||
FINISH_TIMEOUT,
|
||||
);
|
||||
|
||||
// Enough for a refetch, if one were going to happen, to be recorded.
|
||||
await app.waitForTimeout(2500);
|
||||
|
||||
const payload = (event.data as Array<Record<string, unknown>>)[0]!;
|
||||
|
||||
expect(payload['filePath']).toBe(paths[0]);
|
||||
expect(typeof payload['playCount']).toBe('number');
|
||||
expect(payload['playCount']).toBeGreaterThan(0);
|
||||
|
||||
const names = await app.evaluate(() => window.__yjEvents.names());
|
||||
|
||||
expect(
|
||||
names['TrackMetadataChanged'] ?? 0,
|
||||
'a play emitted the retag event, which invalidates every cache',
|
||||
).toBe(0);
|
||||
|
||||
const refetched = await app.evaluate(
|
||||
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
|
||||
);
|
||||
|
||||
expect(
|
||||
refetched.filter((c) => c.startsWith('GetAll')),
|
||||
'a play refetched a collection',
|
||||
).toEqual([]);
|
||||
|
||||
await callBinding(app, 'player.Player.Pause', []);
|
||||
});
|
||||
|
||||
test('leaves the track-list selection alone', async ({ app }) => {
|
||||
// Rows away from the top, so the tracks selected are not the ones
|
||||
// playing and the assertion is about the selection rather than
|
||||
// about what happens to be on screen.
|
||||
await app.evaluate(selectRows, [3, 4, 5]);
|
||||
await app.waitForTimeout(300);
|
||||
|
||||
const selectedBefore = await app.evaluate(selectedPaths);
|
||||
|
||||
expect(selectedBefore).toHaveLength(3);
|
||||
|
||||
const paths = await app.evaluate(firstPaths, 2);
|
||||
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
|
||||
|
||||
// `PlaybackFinished`, deliberately, for two reasons. Starting
|
||||
// playback emits `TrackChanged` immediately, so waiting for that
|
||||
// would assert about a click rather than about a track *finishing*
|
||||
// — the only transition that used to clear the selection. And
|
||||
// unlike `TrackPlayCountChanged` it exists on both sides of this
|
||||
// fix, so reverting the fix makes this spec fail by reporting a
|
||||
// cleared selection rather than by timing out on an event that was
|
||||
// never introduced.
|
||||
await app.evaluate(
|
||||
(ms) => window.__yjEvents.wait('PlaybackFinished', { timeoutMs: ms }),
|
||||
FINISH_TIMEOUT,
|
||||
);
|
||||
await app.waitForTimeout(2000);
|
||||
|
||||
const selectedAfter = await app.evaluate(selectedPaths);
|
||||
|
||||
expect(
|
||||
selectedAfter,
|
||||
'the selection was cleared by a track finishing',
|
||||
).toEqual(selectedBefore);
|
||||
|
||||
await callBinding(app, 'player.Player.Pause', []);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,14 @@ test.describe('queue', () => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
|
||||
// The panel has to be open to have rows. A closed one is `width: 0`
|
||||
// and now renders no list at all (perf.m7) — before that it kept a
|
||||
// virtualizer measuring its window on every queue change, and this
|
||||
// assertion passed against a panel nobody could see.
|
||||
const queueToggle = app.getByRole('button', { name: 'Toggle queue' });
|
||||
|
||||
await queueToggle.click();
|
||||
|
||||
await expect(app.getByTestId('queue-row')).toHaveCount(1);
|
||||
|
||||
const state = await callBinding<{ tracks: unknown[] }>(
|
||||
@@ -97,6 +105,19 @@ test.describe('queue', () => {
|
||||
);
|
||||
|
||||
expect(state.tracks).toHaveLength(1);
|
||||
|
||||
// Shut it again, and wait until it really is shut. These specs
|
||||
// share one backend process in file order, the panel's width is
|
||||
// animated, and the transport slides while it closes — a click
|
||||
// issued during that lands on whichever button has moved under the
|
||||
// pointer, which for the very next test was Repeat rather than
|
||||
// Shuffle. Both emit QueueModeChanged, so it failed on the
|
||||
// assertion rather than on the wait, one run in two.
|
||||
//
|
||||
// Waiting on the row count rather than a timeout is also the m7
|
||||
// assertion: a closed panel renders no list at all.
|
||||
await queueToggle.click();
|
||||
await expect(app.getByTestId('queue-row')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('shuffle and repeat toggles report their state', async ({ app }) => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { rename } from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
callBinding,
|
||||
resetEvents,
|
||||
waitForEvent,
|
||||
LONG_TRACK,
|
||||
} from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Phase 2 of plan 007: the player tells the truth.
|
||||
*
|
||||
* Both specs here are reproductions of measured findings, written
|
||||
* before the fix and failing against the code that shipped Phase 1:
|
||||
*
|
||||
* - `H-3`: the seek bar is a local `setInterval` counter that only
|
||||
* reconciles with the backend on a track change. Measured 3 s behind
|
||||
* during steady playback and **29 s** behind after four keyboard
|
||||
* seeks, because the seek shortcut moves the backend and never tells
|
||||
* the bar.
|
||||
* - `errors.C1`: a track whose file has moved is a silent no-op.
|
||||
* Auto-advance onto it reverts `currentIndex`, stopping the queue
|
||||
* dead with nothing emitted, so playback never reaches the track
|
||||
* after it.
|
||||
*/
|
||||
|
||||
const longRow = (app: Page) =>
|
||||
app.getByTestId('track-row').filter({ hasText: LONG_TRACK }).first();
|
||||
|
||||
/**
|
||||
* Read the displayed clock and the backend position in one round trip.
|
||||
*
|
||||
* Two sequential calls would measure a moving target: at 1 Hz, the
|
||||
* skew between reading the DOM and awaiting a binding is enough to
|
||||
* turn a correct player into a one-second failure.
|
||||
*/
|
||||
async function readClocks(
|
||||
app: Page,
|
||||
): Promise<{ ui: number; backend: number }> {
|
||||
return app.evaluate(async () => {
|
||||
const deep = (root: Document | ShadowRoot): Element | null => {
|
||||
const hit = root.querySelector('[data-testid="elapsed-time"]');
|
||||
|
||||
if (hit) return hit;
|
||||
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.shadowRoot) {
|
||||
const nested = deep(el.shadowRoot);
|
||||
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const text = deep(document)?.textContent?.trim() ?? '';
|
||||
const [mins, secs] = text.split(':').map(Number);
|
||||
|
||||
return {
|
||||
ui: Number.isFinite(mins) && Number.isFinite(secs)
|
||||
? mins * 60 + secs
|
||||
: Number.NaN,
|
||||
backend: (await window.__yjEvents.call(
|
||||
'player.Player.CurrentPositionSeconds',
|
||||
[],
|
||||
5000,
|
||||
)) as number,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move focus off the track row.
|
||||
*
|
||||
* Phase 1 made rows a real grid with roving tabindex, and global
|
||||
* single-key bindings yield to a focused control that owns the key —
|
||||
* so with a row focused the arrows navigate the list rather than
|
||||
* seeking, which is correct and is not what this spec is about.
|
||||
*/
|
||||
async function blurDeepActive(app: Page): Promise<void> {
|
||||
await app.evaluate(() => {
|
||||
const deepActive = (root: Document | ShadowRoot): Element | null => {
|
||||
const active = root.activeElement;
|
||||
|
||||
return active?.shadowRoot ? deepActive(active.shadowRoot) : active;
|
||||
};
|
||||
|
||||
(deepActive(document) as HTMLElement | null)?.blur?.();
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('the player reports its real position', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
});
|
||||
|
||||
test('the elapsed clock tracks the backend during steady playback', async ({
|
||||
app,
|
||||
}) => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readClocks(app)).backend, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(4);
|
||||
|
||||
const { ui, backend } = await readClocks(app);
|
||||
|
||||
expect(Math.abs(ui - backend)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('the elapsed clock survives four keyboard seeks', async ({ app }) => {
|
||||
await longRow(app).dblclick();
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
|
||||
await blurDeepActive(app);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await app.keyboard.press('ArrowRight');
|
||||
}
|
||||
|
||||
// The seek is asynchronous through the backend; give the tick that
|
||||
// reports it a chance to arrive before comparing.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const { ui, backend } = await readClocks(app);
|
||||
|
||||
return Math.abs(ui - backend);
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a finished queue keeps its context', () => {
|
||||
test('the bar still shows what just played, at 0:00', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
const { rows } = (await testctl.sql(
|
||||
"SELECT file_path FROM audio_files WHERE file_path LIKE '%Salt Air%' " +
|
||||
'LIMIT 1',
|
||||
)) as { rows: { file_path: string }[] };
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [
|
||||
[rows[0].file_path],
|
||||
0,
|
||||
false,
|
||||
]);
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
await callBinding(app, 'queue.Queue.Play');
|
||||
|
||||
await waitForEvent(app, 'PlaybackFinished', { timeoutMs: 30_000 });
|
||||
|
||||
// H-18: the bar used to blank completely while the queue panel
|
||||
// still listed the track that had just played.
|
||||
await expect(app.getByTestId('now-playing-title')).toContainText(
|
||||
'Salt Air',
|
||||
);
|
||||
await expect(app.getByTestId('elapsed-time')).toHaveText('00:00');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a track that will not play says so', () => {
|
||||
test('auto-advance skips a missing file and reaches the next track', async ({
|
||||
app,
|
||||
testctl,
|
||||
}) => {
|
||||
const { rows } = (await testctl.sql(
|
||||
"SELECT file_path FROM audio_files WHERE file_path LIKE '%Glass Harbour%' " +
|
||||
'ORDER BY file_path LIMIT 3',
|
||||
)) as { rows: { file_path: string }[] };
|
||||
|
||||
expect(rows).toHaveLength(3);
|
||||
|
||||
const paths = rows.map((r) => r.file_path);
|
||||
const missing = paths[1];
|
||||
const hidden = `${missing}.e2e-hidden`;
|
||||
|
||||
test.setTimeout(90_000);
|
||||
|
||||
await rename(missing, hidden);
|
||||
|
||||
try {
|
||||
await callBinding(app, 'queue.Queue.Clear');
|
||||
await resetEvents(app);
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
||||
await waitForEvent(app, 'QueueChanged');
|
||||
await callBinding(app, 'queue.Queue.Play');
|
||||
|
||||
// The first fixture is ~6 s long; the whole hop through the bad
|
||||
// file and onto the third track has to happen inside that plus
|
||||
// the third track's own length.
|
||||
const failed = await waitForEvent(app, 'PlaybackFailed', {
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(failed.data[0]).toMatchObject({ filePath: missing });
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await callBinding<{ currentIndex: number }>(
|
||||
app,
|
||||
'queue.Queue.GetState',
|
||||
)
|
||||
).currentIndex,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(2);
|
||||
|
||||
// Not just skipped — said so. A failure the user cannot see is
|
||||
// the finding, not the fix.
|
||||
await expect(app.getByTestId('player-message')).toContainText(
|
||||
/could not (be )?play/i,
|
||||
);
|
||||
} finally {
|
||||
await rename(hidden, missing);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
eventNames,
|
||||
resetEvents,
|
||||
waitForEvent,
|
||||
} from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* Primary views are cached, not unmounted, so that `scrollTop` survives
|
||||
* navigation (`frontend/index.ts`). The cost of that decision is that
|
||||
* `disconnectedCallback` never fires, so anything a view registered on
|
||||
* `document` keeps running from every other page.
|
||||
*
|
||||
* H-1 in `.planning/audits/2026-08-11-ui/hands-on.md` is the worst case:
|
||||
* `autotag-view`'s document keydown handler binds `s` to "skip this
|
||||
* album", so pressing `s` on Settings — where `s` is the global shuffle
|
||||
* shortcut — silently removed albums from the autotag queue. The same
|
||||
* handler binds `a` to Apply, which rewrites tags on disk.
|
||||
*
|
||||
* The invariant this asserts is the whole of Phase 1: a view that is not
|
||||
* on screen is not listening.
|
||||
*/
|
||||
test.describe('view lifecycle', () => {
|
||||
/** The autotag sidebar's "Pending (N)" header.
|
||||
*
|
||||
* Read with `textContent`, not Playwright's text matchers: once the
|
||||
* view is off-screen it is `.view-hidden`, so every visibility-aware
|
||||
* API reports it as empty — which would make this spec pass for the
|
||||
* wrong reason. */
|
||||
const pendingCount = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => {
|
||||
const view = document.querySelector('autotag-view');
|
||||
const header = view?.shadowRoot?.querySelector('.folders-header');
|
||||
|
||||
return header?.textContent?.trim() ?? '';
|
||||
});
|
||||
|
||||
test('a keypress on Settings does not reach the Autotag queue', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('nav-autotag').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'autotag',
|
||||
);
|
||||
await expect
|
||||
.poll(() => pendingCount(app))
|
||||
.toMatch(/^Pending \(\d+\)$/);
|
||||
|
||||
const before = await pendingCount(app);
|
||||
|
||||
await app.getByTestId('nav-settings').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'settings',
|
||||
);
|
||||
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('s');
|
||||
|
||||
// The global binding is shuffle, and it must be the *only* thing
|
||||
// that happened.
|
||||
await expect
|
||||
.poll(() => eventNames(app).then((n) => n.QueueModeChanged ?? 0))
|
||||
.toBe(1);
|
||||
|
||||
expect(await pendingCount(app)).toBe(before);
|
||||
});
|
||||
|
||||
test('on Autotag, the same key skips and does not also shuffle', async ({
|
||||
app,
|
||||
}) => {
|
||||
// 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*
|
||||
// toggled shuffle. As a panel binding it can only mean one thing.
|
||||
await app.getByTestId('nav-autotag').click();
|
||||
await expect
|
||||
.poll(() => pendingCount(app))
|
||||
.toMatch(/^Pending \(\d+\)$/);
|
||||
|
||||
const before = Number(/\((\d+)\)/.exec(await pendingCount(app))![1]);
|
||||
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('s');
|
||||
|
||||
await expect.poll(() => pendingCount(app)).toBe(`Pending (${before - 1})`);
|
||||
expect((await eventNames(app)).QueueModeChanged ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* H-5: tabbing through the app produced fourteen stops and not one of
|
||||
* them was navigation or content. These are the two that matter most —
|
||||
* getting *into* the app, and doing the app's primary action once there.
|
||||
*/
|
||||
test.describe('keyboard reach', () => {
|
||||
/** The deepest focused element, resolved through shadow roots the way
|
||||
* the shortcut service does — `document.activeElement` stops at the
|
||||
* host and would report every stop as the same element. */
|
||||
const focused = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => {
|
||||
let el: Element | null = document.activeElement;
|
||||
|
||||
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
||||
|
||||
return {
|
||||
tag: el?.tagName ?? '',
|
||||
testid: (el as HTMLElement | null)?.dataset?.['testid'] ?? '',
|
||||
role: el?.getAttribute('role') ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
test('tabs out of the header straight into the sidebar', async ({
|
||||
app,
|
||||
}) => {
|
||||
// Started from the search box rather than from the top of the page:
|
||||
// the header's leading controls come and go (the index-status button
|
||||
// is only there while the index builds), so counting stops from the
|
||||
// start makes the assertion about the header, not about the nav.
|
||||
await app.evaluate(() => {
|
||||
document
|
||||
.querySelector('search-bar')
|
||||
?.shadowRoot?.querySelector('input')
|
||||
?.focus();
|
||||
});
|
||||
|
||||
await app.keyboard.press('Tab');
|
||||
|
||||
expect(await focused(app)).toMatchObject({ testid: 'nav-home' });
|
||||
});
|
||||
|
||||
test('a track row can be reached and played without a mouse', async ({
|
||||
app,
|
||||
}) => {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
|
||||
// Tab until the list's single stop — the roving tabindex means there
|
||||
// is exactly one, however many thousand rows there are.
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
await app.keyboard.press('Tab');
|
||||
|
||||
if ((await focused(app)).role === 'row') break;
|
||||
}
|
||||
|
||||
expect(await focused(app)).toMatchObject({ role: 'row' });
|
||||
|
||||
await app.keyboard.press('ArrowDown');
|
||||
await resetEvents(app);
|
||||
await app.keyboard.press('Enter');
|
||||
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ body div.sidebar {
|
||||
padding: 0.25em;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
display: grid;
|
||||
grid-template-columns: var(--now-playing-width, 200px) 1fr auto;
|
||||
grid-template-columns: var(--now-playing-width, 320px) 1fr auto;
|
||||
align-items: center;
|
||||
contain: layout style;
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
</button>
|
||||
</footer>
|
||||
<first-run-wizard></first-run-wizard>
|
||||
<notification-host></notification-host>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// What is imported here is what is parsed and evaluated before first
|
||||
// paint. Everything else is a chunk fetched on the navigation that
|
||||
// needs it (VIEW_LOADERS / DETAIL_LOADERS below) and warmed in the
|
||||
// background once the app is idle, so only the *first* second of the
|
||||
// session pays for the views the user has not asked for.
|
||||
//
|
||||
// Three things stay eager that look like candidates:
|
||||
//
|
||||
// notification-host, inline-notice, confirm-dialog — the app's only
|
||||
// failure surface. A message that has to fetch a chunk before it can
|
||||
// be shown is not a failure surface; the moment it is most needed is
|
||||
// exactly the moment loading one may not work.
|
||||
//
|
||||
// first-run-wizard — it covers the app until a library exists, so on
|
||||
// the one launch it matters it is on the critical path anyway.
|
||||
//
|
||||
// track-list — index.html renders one, so it is the first paint.
|
||||
// ---------------------------------------------------------------------------
|
||||
import '@components/audio-player/audio-player.ts';
|
||||
import '@components/track-list/track-list.ts';
|
||||
import '@components/cover-grid/cover-grid.ts';
|
||||
import '@components/now-playing/now-playing.ts';
|
||||
import '@components/sidebar/app-sidebar.ts';
|
||||
import '@components/queue-panel/queue-panel.ts';
|
||||
import '@components/playlist-view/playlist-view.ts';
|
||||
import '@components/config-page/config-page.ts';
|
||||
import '@components/artists-view/artists-view.ts';
|
||||
import '@components/artist-details/artist-details.ts';
|
||||
import '@components/genres-view/genres-view.ts';
|
||||
import '@components/genre-details/genre-details.ts';
|
||||
import '@components/playlist-details/playlist-details.ts';
|
||||
import '@components/smart-playlist-details/smart-playlist-details.ts';
|
||||
import '@components/smart-playlist-editor/smart-playlist-editor.ts';
|
||||
import '@components/search-bar/search-bar.ts';
|
||||
import '@components/library-filter/library-filter.ts';
|
||||
import '@components/track-details/track-details.ts';
|
||||
import '@components/explore-view/explore-view.ts';
|
||||
import '@components/explore-artist-details/explore-artist-details.js';
|
||||
import '@components/explore-album-details/explore-album-details.js';
|
||||
import '@components/autotag-view/autotag-view.ts';
|
||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||
import '@components/notifications/notification-host.ts';
|
||||
import '@components/notifications/inline-notice.ts';
|
||||
import '@components/confirm-dialog/confirm-dialog.ts';
|
||||
import '@components/jobs/job-indicator.ts';
|
||||
import '@components/jobs/jobs-view.ts';
|
||||
import '@components/downloads-view/downloads-view.ts';
|
||||
import '@components/home-view/home-view.ts';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
import { registerBundledIcons } from './src/icons';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import { searchStore } from '@store/search-store';
|
||||
import * as Player from '@go/player/Player';
|
||||
@@ -38,6 +43,7 @@ import '@store/theme-store';
|
||||
// Importing the keyboard shortcut service triggers initialization:
|
||||
// registers the document keydown listener for global shortcuts.
|
||||
import './src/services/keyboard-shortcut-service';
|
||||
import { activateView, deactivateView } from '@utils/view-lifecycle';
|
||||
import {
|
||||
hasTrackPayload,
|
||||
getDragPayload,
|
||||
@@ -46,6 +52,12 @@ import type { DragActiveDetail } from '@utils/drag-controller';
|
||||
|
||||
setBasePath('/dist/webawesome');
|
||||
|
||||
// Before any component renders: an icon resolved by the default
|
||||
// (remote) library is a request to fontawesome.com, and the module
|
||||
// caches by URL, so one early render would pin the remote answer for
|
||||
// the session.
|
||||
registerBundledIcons();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View caching navigation system
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -55,6 +67,13 @@ setBasePath('/dist/webawesome');
|
||||
// innerHTML. Detail views (artist-details, playlist-details, genre-details)
|
||||
// are ephemeral — created fresh each navigation because they depend on
|
||||
// specific entity IDs that change.
|
||||
//
|
||||
// Because a cached view is never disconnected, `disconnectedCallback` is
|
||||
// not where it stops listening. Navigation calls viewDeactivated() on
|
||||
// the outgoing element and viewActivated() on the incoming one; views
|
||||
// hang their document listeners, timers and subscriptions off that pair
|
||||
// (see utils/view-lifecycle.ts). Skipping either call leaves a view
|
||||
// listening from a page it is not on, which is finding H-1.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VIEW_TAGS: Record<string, string> = {
|
||||
@@ -71,6 +90,55 @@ const VIEW_TAGS: Record<string, string> = {
|
||||
settings: 'config-page',
|
||||
};
|
||||
|
||||
// The module that defines each view's custom element. `createElement`
|
||||
// on an undefined tag silently produces an inert HTMLElement rather
|
||||
// than throwing, so a navigation has to await its loader before it
|
||||
// builds anything — a missing entry here is a blank page, not an error.
|
||||
const VIEW_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||
home: () => import('@components/home-view/home-view.ts'),
|
||||
tracks: () => Promise.resolve(),
|
||||
albums: () => import('@components/cover-grid/cover-grid.ts'),
|
||||
artists: () => import('@components/artists-view/artists-view.ts'),
|
||||
genres: () => import('@components/genres-view/genres-view.ts'),
|
||||
playlists: () => import('@components/playlist-view/playlist-view.ts'),
|
||||
explore: () => import('@components/explore-view/explore-view.ts'),
|
||||
autotag: () => import('@components/autotag-view/autotag-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'),
|
||||
};
|
||||
|
||||
const DETAIL_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||
'artist-details': () =>
|
||||
import('@components/artist-details/artist-details.ts'),
|
||||
'playlist-details': () =>
|
||||
import('@components/playlist-details/playlist-details.ts'),
|
||||
'smart-playlist-details': () =>
|
||||
import('@components/smart-playlist-details/smart-playlist-details.ts'),
|
||||
'genre-details': () =>
|
||||
import('@components/genre-details/genre-details.ts'),
|
||||
'explore-artist-details': () =>
|
||||
import('@components/explore-artist-details/explore-artist-details.js'),
|
||||
'explore-album-details': () =>
|
||||
import('@components/explore-album-details/explore-album-details.js'),
|
||||
};
|
||||
|
||||
// Opened from a menu rather than by navigating, so they have no entry
|
||||
// above; warmed with everything else below.
|
||||
//
|
||||
// `track-details` is loaded at the point of use by
|
||||
// `utils/lazy-track-details.ts`, from the five components that open it
|
||||
// (`track-list`, `cover-grid`, `queue-panel` and both playlist detail
|
||||
// views). It used to be imported statically by all five, so its 42 kB
|
||||
// rode in the startup chunk whatever this file said. Warming it here
|
||||
// means the first open still does not wait for it.
|
||||
const EXTRA_LOADERS: Array<() => Promise<unknown>> = [
|
||||
() => import('@components/smart-playlist-editor/smart-playlist-editor.ts'),
|
||||
// Same specifier as `utils/lazy-track-details.ts` uses, so this is
|
||||
// the same chunk rather than a second copy of it.
|
||||
() => import('@components/track-details/track-details.js'),
|
||||
];
|
||||
|
||||
const viewCache = new Map<string, HTMLElement>();
|
||||
let currentViewEl: HTMLElement | null = null;
|
||||
let currentDetailEl: HTMLElement | null = null;
|
||||
@@ -89,15 +157,36 @@ if (mainContent) {
|
||||
if (initialTrackList) {
|
||||
viewCache.set('tracks', initialTrackList as HTMLElement);
|
||||
currentViewEl = initialTrackList as HTMLElement;
|
||||
activateView(currentViewEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigations are numbered, because loading a view's chunk is
|
||||
* asynchronous and a user can click twice. Anything after the `await`
|
||||
* checks that it is still the newest navigation before touching the
|
||||
* DOM; otherwise a slow chunk would land on top of a faster one and
|
||||
* show the page the user navigated *away* from.
|
||||
*/
|
||||
let navSeq = 0;
|
||||
|
||||
document.addEventListener('navigate', (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
void handleNavigate((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
async function handleNavigate(
|
||||
detail: { view: string; [key: string]: any },
|
||||
): Promise<void> {
|
||||
const view: string = detail.view;
|
||||
|
||||
if (!mainContent) return;
|
||||
|
||||
const seq = ++navSeq;
|
||||
|
||||
// Bookkeeping stays synchronous with the click: the search box's
|
||||
// scope and the active-view attribute describe the navigation that
|
||||
// was *asked for*, and are what the rest of the app and the e2e
|
||||
// selectors read.
|
||||
searchStore.setCurrentView(view);
|
||||
|
||||
// Which view is showing is otherwise only inferable from which of
|
||||
@@ -112,6 +201,7 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
|
||||
// Remove any active detail view first
|
||||
if (currentDetailEl) {
|
||||
deactivateView(currentDetailEl);
|
||||
currentDetailEl.remove();
|
||||
currentDetailEl = null;
|
||||
}
|
||||
@@ -119,6 +209,9 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
let target = viewCache.get(view);
|
||||
|
||||
if (!target) {
|
||||
await (VIEW_LOADERS[view]?.() ?? Promise.resolve());
|
||||
if (seq !== navSeq) return;
|
||||
|
||||
target = document.createElement(VIEW_TAGS[view]);
|
||||
viewCache.set(view, target);
|
||||
// Start hidden — we'll un-hide below
|
||||
@@ -130,13 +223,22 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
// display:none so scroll containers preserve scrollTop.
|
||||
if (currentViewEl && currentViewEl !== target) {
|
||||
currentViewEl.classList.add('view-hidden');
|
||||
deactivateView(currentViewEl);
|
||||
}
|
||||
target.classList.remove('view-hidden');
|
||||
// A freshly created view was appended hidden, so it did not
|
||||
// self-activate on connection; a cached one was deactivated on
|
||||
// the way out. Either way this is the call that starts it.
|
||||
activateView(target);
|
||||
currentViewEl = target;
|
||||
currentNavDetail = { view };
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await (DETAIL_LOADERS[view]?.() ?? Promise.resolve());
|
||||
if (seq !== navSeq) return;
|
||||
|
||||
// --- Detail (ephemeral) views -----------------------------------------
|
||||
// Push the current view onto the nav stack before switching
|
||||
// (unless this is a back-navigation, which already popped).
|
||||
@@ -147,9 +249,11 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
// Hide the current primary view
|
||||
if (currentViewEl) {
|
||||
currentViewEl.classList.add('view-hidden');
|
||||
deactivateView(currentViewEl);
|
||||
}
|
||||
// Remove any prior detail element
|
||||
if (currentDetailEl) {
|
||||
deactivateView(currentDetailEl);
|
||||
currentDetailEl.remove();
|
||||
currentDetailEl = null;
|
||||
}
|
||||
@@ -245,7 +349,52 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
currentDetailEl = fallback;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Every view the user has not opened yet, fetched once the app has
|
||||
// settled. Splitting keeps them off the path to first paint; warming
|
||||
// them means the navigation that needs one almost never waits, which is
|
||||
// the cost a naive split would have traded the startup win for.
|
||||
function warmViewChunks(): void {
|
||||
const loaders = [
|
||||
...Object.values(VIEW_LOADERS),
|
||||
...Object.values(DETAIL_LOADERS),
|
||||
...EXTRA_LOADERS,
|
||||
];
|
||||
|
||||
const warmNext = (i: number): void => {
|
||||
if (i >= loaders.length) return;
|
||||
|
||||
void loaders[i]!()
|
||||
.catch(() => {
|
||||
// A chunk that will not preload is not a failure: the
|
||||
// navigation that needs it will ask again and report
|
||||
// for itself if it still cannot be had.
|
||||
})
|
||||
.finally(() => {
|
||||
schedule(() => warmNext(i + 1));
|
||||
});
|
||||
};
|
||||
|
||||
schedule(() => warmNext(0));
|
||||
}
|
||||
|
||||
/** requestIdleCallback where it exists; WebKit2GTK does not have it. */
|
||||
function schedule(fn: () => void): void {
|
||||
const ric = (
|
||||
window as unknown as {
|
||||
requestIdleCallback?: (cb: () => void) => number;
|
||||
}
|
||||
).requestIdleCallback;
|
||||
|
||||
if (ric) {
|
||||
ric(fn);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(fn, 200);
|
||||
}
|
||||
|
||||
// Navigate-back: pop the nav stack and re-dispatch as a regular navigate.
|
||||
document.addEventListener('navigate-back', () => {
|
||||
@@ -328,3 +477,5 @@ if (queueButton && queuePanel) {
|
||||
// or timing assumptions needed.
|
||||
void Player.EmitCurrentState();
|
||||
void Queue.EmitCurrentState();
|
||||
|
||||
warmViewChunks();
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Vendor the icon set into the repo.
|
||||
*
|
||||
* The app used to fetch every `<wa-icon>` from ka-f.fontawesome.com at
|
||||
* runtime, which meant it had no icons at all offline, on a captive
|
||||
* portal or behind a firewall (audit H-4 / perf.M9). Bundling them is
|
||||
* the fix; this script is how the bundle is produced, so "where did
|
||||
* these SVGs come from" has an answer that is a command rather than a
|
||||
* memory.
|
||||
*
|
||||
* IT MUST BE FONT AWESOME **FREE**. The kit CDN the app was hitting
|
||||
* serves *Pro* SVGs — every file carries a "Commercial License"
|
||||
* comment — and those cannot be redistributed in this repository. The
|
||||
* Free set is CC BY 4.0, which can, with attribution; LICENSE.txt is
|
||||
* copied next to the icons for exactly that reason. Every name the app
|
||||
* uses happens to exist in Free, so this costs nothing visually, but
|
||||
* a future addition might not: if a name is missing here, pick a
|
||||
* different icon rather than reaching for the Pro one.
|
||||
*
|
||||
* Usage:
|
||||
* node frontend/scripts/fetch-icons.mjs
|
||||
*
|
||||
* Reads names from src/icons/names.txt, writes src/assets/icons/fa/.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync,
|
||||
readdirSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FRONTEND = resolve(HERE, '..');
|
||||
const NAMES = resolve(FRONTEND, 'src/icons/names.txt');
|
||||
const DEST = resolve(FRONTEND, 'src/assets/icons/fa');
|
||||
const PKG = '@fortawesome/fontawesome-free@7.3.1';
|
||||
|
||||
const names = readFileSync(NAMES, 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/#.*$/, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), 'yj-icons-'));
|
||||
|
||||
try {
|
||||
execFileSync('npm', ['pack', PKG], { cwd: work, stdio: 'pipe' });
|
||||
const tgz = readdirSync(work).find((f) => f.endsWith('.tgz'));
|
||||
execFileSync('tar', ['xf', tgz], { cwd: work });
|
||||
|
||||
const src = join(work, 'package');
|
||||
rmSync(DEST, { recursive: true, force: true });
|
||||
|
||||
for (const name of names) {
|
||||
const from = join(src, 'svgs', `${name}.svg`);
|
||||
if (!existsSync(from)) {
|
||||
console.error(
|
||||
`fetch-icons: '${name}' is not in Font Awesome Free.\n` +
|
||||
' Pick an icon that is; do not vendor the Pro version.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const to = join(DEST, `${name}.svg`);
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
}
|
||||
|
||||
copyFileSync(join(src, 'LICENSE.txt'), join(DEST, 'LICENSE.txt'));
|
||||
console.log(`fetch-icons: vendored ${names.length} icons from ${PKG}`);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Which icons does this app actually use?
|
||||
*
|
||||
* A static grep cannot answer that: twenty call sites pass a computed
|
||||
* name (`this.favCtrl.iconName`, `jobIcon(job)`, `TONE_ICONS[tone]`),
|
||||
* and the answer depends on state. So ask the running app instead —
|
||||
* before the icons are bundled, every one of them is a request to
|
||||
* ka-f.fontawesome.com, which makes the CDN request log an exact
|
||||
* inventory of what has to be vendored.
|
||||
*
|
||||
* This is a one-shot development tool, not part of any build. It is
|
||||
* kept because the list it produces will go stale the first time a
|
||||
* component grows a new state, and rerunning it is the cheapest way to
|
||||
* find out. `frontend/src/icons/manifest.ts` is the committed answer.
|
||||
*
|
||||
* Usage: make dev-headless SEED=default, then
|
||||
* node frontend/scripts/icon-sweep.mjs
|
||||
*/
|
||||
|
||||
import { chromium } from '../../e2e/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const URL_BASE = process.env.YJ_URL ?? 'http://localhost:34115';
|
||||
|
||||
const VIEWS = [
|
||||
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
|
||||
'explore', 'autotag', 'downloads', 'jobs', 'settings',
|
||||
];
|
||||
|
||||
const found = new Set();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
page.on('request', (req) => {
|
||||
const m = /fontawesome\.com\/.*\/svgs\/([^/]+)\/([^/?]+)\.svg/.exec(req.url());
|
||||
if (m) found.add(`${m[1]}/${m[2]}`);
|
||||
});
|
||||
|
||||
await page.goto(URL_BASE, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
for (const view of VIEWS) {
|
||||
await page.evaluate((v) => document.dispatchEvent(
|
||||
new CustomEvent('navigate', { detail: { view: v } }),
|
||||
), view);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
// Also collect what is in the DOM but may have been served from the
|
||||
// icon module's own cache rather than re-requested.
|
||||
const inDom = await page.evaluate(() => {
|
||||
const names = new Set();
|
||||
const walk = (root) => {
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
if (el.tagName === 'WA-ICON' && el.getAttribute('name')) {
|
||||
names.add(el.getAttribute('name'));
|
||||
}
|
||||
if (el.shadowRoot) walk(el.shadowRoot);
|
||||
}
|
||||
};
|
||||
walk(document);
|
||||
|
||||
return [...names];
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
for (const n of inDom) {
|
||||
if (![...found].some((f) => f.endsWith('/' + n))) found.add(`?/${n}`);
|
||||
}
|
||||
|
||||
console.log([...found].sort().join('\n'));
|
||||
console.log(`\n${found.size} icons`);
|
||||
@@ -0,0 +1,165 @@
|
||||
Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Font Awesome Free License
|
||||
|
||||
Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Full Font Awesome Free license: https://fontawesome.com/license/free.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
The Font Awesome Free download is licensed under a Creative Commons
|
||||
Attribution 4.0 International License and applies to all icons packaged
|
||||
as SVG and JS file types.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Fonts: SIL OFL 1.1 License
|
||||
|
||||
In the Font Awesome Free download, the SIL OFL license applies to all icons
|
||||
packaged as web and desktop font files.
|
||||
|
||||
Copyright (c) 2026 Fonticons, Inc. (https://fontawesome.com)
|
||||
with Reserved Font Name: "Font Awesome".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting — in part or in whole — any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Code: MIT License (https://opensource.org/licenses/MIT)
|
||||
|
||||
In the Font Awesome Free download, the MIT license applies to all non-font and
|
||||
non-icon files.
|
||||
|
||||
Copyright 2026 Fonticons, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Attribution
|
||||
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
|
||||
Awesome Free files already contain embedded comments with sufficient
|
||||
attribution, so you shouldn't need to do anything additional when using these
|
||||
files normally.
|
||||
|
||||
We've kept attribution comments terse, so we ask that you do not actively work
|
||||
to remove them from files, especially code. They're a great way for folks to
|
||||
learn about Font Awesome.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Brand Icons
|
||||
|
||||
All brand icons are trademarks of their respective owners. The use of these
|
||||
trademarks does not indicate endorsement of the trademark holder by Font
|
||||
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
|
||||
to represent the company, product, or service to which they refer.**
|
||||
@@ -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="M378.9 80c-27.3 0-53 13.1-69 35.2l-34.4 47.6c-4.5 6.2-11.7 9.9-19.4 9.9s-14.9-3.7-19.4-9.9l-34.4-47.6c-16-22.1-41.7-35.2-69-35.2-47 0-85.1 38.1-85.1 85.1 0 49.9 32 98.4 68.1 142.3 41.1 50 91.4 94 125.9 120.3 3.2 2.4 7.9 4.2 14 4.2s10.8-1.8 14-4.2c34.5-26.3 84.8-70.4 125.9-120.3 36.2-43.9 68.1-92.4 68.1-142.3 0-47-38.1-85.1-85.1-85.1zM271 87.1c25-34.6 65.2-55.1 107.9-55.1 73.5 0 133.1 59.6 133.1 133.1 0 68.6-42.9 128.9-79.1 172.8-44.1 53.6-97.3 100.1-133.8 127.9-12.3 9.4-27.5 14.1-43.1 14.1s-30.8-4.7-43.1-14.1C176.4 438 123.2 391.5 79.1 338 42.9 294.1 0 233.7 0 165.1 0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1l15 20.7 15-20.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 951 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 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="M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z"/></svg>
|
||||
|
After Width: | Height: | Size: 889 B |
@@ -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="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -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="M436.7 74.7L448 85.4 448 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 128c0 17.7-14.3 32-32 32l-128 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l47.9 0-7.6-7.2c-.2-.2-.4-.4-.6-.6-75-75-196.5-75-271.5 0s-75 196.5 0 271.5 196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c99.9-99.9 261.7-100 361.7-.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 705 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 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="M320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l32 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-32 0zm0 128c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0zm0 128c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0zm0 128c-17.7 0-32 14.3-32 32s14.3 32 32 32l224 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-224 0zM150.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-96 96c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L96 141.3 96 448c0 17.7 14.3 32 32 32s32-14.3 32-32l0-306.7 41.4 41.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-96-96z"/></svg>
|
||||
|
After Width: | Height: | Size: 888 B |
@@ -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="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 1002 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M363 36.8c-12.9-7-28.7-6.3-41 1.8L64 208.1 64 64c0-17.7-14.3-32-32-32S0 46.3 0 64L0 448c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144.1 258 169.6c12.3 8.1 28 8.8 41 1.8s21-20.5 21-35.2l0-368c0-14.7-8.1-28.2-21-35.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M64 0C28.7 0 0 28.7 0 64L0 480c0 11.5 6.2 22.2 16.2 27.8s22.3 5.5 32.2-.4L192 421.3 335.5 507.4c9.9 5.9 22.2 6.1 32.2 .4S384 491.5 384 480l0-416c0-35.3-28.7-64-64-64L64 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 482 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 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="M560.3 237.2c10.4 11.8 28.3 14.4 41.8 5.5 14.7-9.8 18.7-29.7 8.9-44.4l-48-72c-2.8-4.2-6.6-7.7-11.1-10.2L351.4 4.7c-19.3-10.7-42.8-10.7-62.2 0L88.8 116c-5.4 3-9.7 7.4-12.6 12.8L27.7 218.7c-12.6 23.4-3.8 52.5 19.6 65.1l33 17.7 0 53.3c0 23 12.4 44.3 32.4 55.7l176 99.7c19.6 11.1 43.5 11.1 63.1 0l176-99.7c20.1-11.4 32.4-32.6 32.4-55.7l0-117.5zm-240-9.8L170.2 144 320.3 60.6 470.4 144 320.3 227.4zm-41.5 50.2l-21.3 46.2-165.8-88.8 25.4-47.2 161.7 89.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M201.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 338.7 54.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"/></svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 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="M311.1 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L243.2 256 73.9 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -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="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 546 B |
@@ -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="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zm0-192a32 32 0 1 0 0 64 32 32 0 1 0 0-64zm0-192c-18.2 0-32.7 15.5-31.4 33.7l7.4 104c.9 12.6 11.4 22.3 23.9 22.3 12.6 0 23-9.7 23.9-22.3l7.4-104c1.3-18.2-13.1-33.7-31.4-33.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -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="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM224 160a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm-8 64l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>
|
||||
|
After Width: | Height: | Size: 575 B |
@@ -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="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM168 232l176 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-176 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/></svg>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -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="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zm0-336c-17.7 0-32 14.3-32 32 0 13.3-10.7 24-24 24s-24-10.7-24-24c0-44.2 35.8-80 80-80s80 35.8 80 80c0 47.2-36 67.2-56 74.5l0 3.8c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-8.1c0-20.5 14.8-35.2 30.1-40.2 6.4-2.1 13.2-5.5 18.2-10.3 4.3-4.2 7.7-10 7.7-19.6 0-17.7-14.3-32-32-32zM224 368a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 674 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 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="M288 64c106 0 192 86 192 192S394 448 288 448c-65.2 0-122.9-32.5-157.6-82.3-10.1-14.5-30.1-18-44.6-7.9s-18 30.1-7.9 44.6C124.1 468.6 201 512 288 512 429.4 512 544 397.4 544 256S429.4 0 288 0C202.3 0 126.5 42.1 80 106.7L80 80c0-17.7-14.3-32-32-32S16 62.3 16 80l0 112c0 17.7 14.3 32 32 32l24.6 0c.5 0 1 0 1.5 0l86 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-38.3 0C154.9 102.6 217 64 288 64zm24 88c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65 0-94.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 823 B |
@@ -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="M0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0zm256 32a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm-96-32a96 96 0 1 0 192 0 96 96 0 1 0 -192 0zm-56-16c0-32.4 16.3-66.6 42.8-93.2S207.6 104 240 104c13.3 0 24-10.7 24-24s-10.7-24-24-24c-47.9 0-93.7 23.5-127.1 56.9S56 192.1 56 240c0 13.3 10.7 24 24 24s24-10.7 24-24z"/></svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M448 205.8c-14.8 9.8-31.8 17.7-49.5 24-47 16.8-108.7 26.2-174.5 26.2S96.4 246.5 49.5 229.8c-17.6-6.3-34.7-14.2-49.5-24L0 288c0 44.2 100.3 80 224 80s224-35.8 224-80l0-82.2zm0-77.8l0-48C448 35.8 347.7 0 224 0S0 35.8 0 80l0 48c0 44.2 100.3 80 224 80s224-35.8 224-80zM398.5 389.8C351.6 406.5 289.9 416 224 416S96.4 406.5 49.5 389.8c-17.6-6.3-34.7-14.2-49.5-24L0 432c0 44.2 100.3 80 224 80s224-35.8 224-80l0-66.2c-14.8 9.8-31.8 17.7-49.5 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 747 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M64 0C28.7 0 0 28.7 0 64l0 240 182.1 0-31-31c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l72 72c9.4 9.4 9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l31-31-182.1 0 0 96c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/></svg>
|
||||
|
After Width: | Height: | Size: 677 B |
@@ -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="M32 64C19.1 64 7.4 71.8 2.4 83.8S.2 109.5 9.4 118.6L192 301.3 192 416c0 8.5 3.4 16.6 9.4 22.6l64 64c9.2 9.2 22.9 11.9 34.9 6.9S320 492.9 320 480l0-178.7 182.6-182.6c9.2-9.2 11.9-22.9 6.9-34.9S492.9 64 480 64L32 64z"/></svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>
|
||||
|
After Width: | Height: | Size: 626 B |
@@ -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="M64 448l384 0c35.3 0 64-28.7 64-64l0-240c0-35.3-28.7-64-64-64L298.7 80c-6.9 0-13.7-2.2-19.2-6.4L241.1 44.8C230 36.5 216.5 32 202.7 32L64 32C28.7 32 0 60.7 0 96L0 384c0 35.3 28.7 64 64 64z"/></svg>
|
||||
|
After Width: | Height: | Size: 498 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M21 36.8c12.9-7 28.7-6.3 41 1.8L320 208.1 320 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 384c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-144.1-258 169.6c-12.3 8.1-28 8.8-41 1.8S0 454.7 0 440L0 72C0 57.3 8.1 43.8 21 36.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 522 B |
@@ -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="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -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="M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 1018 B |
@@ -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="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -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="M277.8 8.6c-12.3-11.4-31.3-11.4-43.5 0l-224 208c-9.6 9-12.8 22.9-8 35.1S18.8 272 32 272l16 0 0 176c0 35.3 28.7 64 64 64l288 0c35.3 0 64-28.7 64-64l0-176 16 0c13.2 0 25-8.1 29.8-20.3s1.6-26.2-8-35.1l-224-208zM240 320l32 0c26.5 0 48 21.5 48 48l0 96-128 0 0-96c0-26.5 21.5-48 48-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M32 0C14.3 0 0 14.3 0 32S14.3 64 32 64l0 11c0 42.4 16.9 83.1 46.9 113.1l67.9 67.9-67.9 67.9C48.9 353.9 32 394.6 32 437l0 11c-17.7 0-32 14.3-32 32s14.3 32 32 32l320 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l0-11c0-42.4-16.9-83.1-46.9-113.1l-67.9-67.9 67.9-67.9c30-30 46.9-70.7 46.9-113.1l0-11c17.7 0 32-14.3 32-32S369.7 0 352 0L32 0zM96 75l0-11 192 0 0 11c0 19-5.6 37.4-16 53L112 128c-10.3-15.6-16-34-16-53zm16 309c3.5-5.3 7.6-10.3 12.1-14.9l67.9-67.9 67.9 67.9c4.6 4.6 8.6 9.6 12.2 14.9L112 384z"/></svg>
|
||||
|
After Width: | Height: | Size: 802 B |
@@ -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="M277.8 8.6c-12.3-11.4-31.3-11.4-43.5 0l-224 208c-9.6 9-12.8 22.9-8 35.1S18.8 272 32 272l16 0 0 176c0 35.3 28.7 64 64 64l288 0c35.3 0 64-28.7 64-64l0-176 16 0c13.2 0 25-8.1 29.8-20.3s1.6-26.2-8-35.1l-224-208zM240 320l32 0c26.5 0 48 21.5 48 48l0 96-128 0 0-96c0-26.5 21.5-48 48-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 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="M96 96c0-35.3 28.7-64 64-64l320 0c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64l-320 0c-35.3 0-64-28.7-64-64L96 96zM24 128c13.3 0 24 10.7 24 24l0 296c0 8.8 7.2 16 16 16l360 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L64 512c-35.3 0-64-28.7-64-64L0 152c0-13.3 10.7-24 24-24zm168 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm196.5 11.5c-4.4-7.1-12.1-11.5-20.5-11.5s-16.1 4.4-20.5 11.5l-56.3 92.1-24.5-30.6c-4.6-5.7-11.4-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4S174.8 352 184 352l272 0c8.7 0 16.7-4.7 20.9-12.3s4.1-16.8-.5-24.3l-88-144z"/></svg>
|
||||
|
After Width: | Height: | Size: 852 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 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="M48 48a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM0 192c0-17.7 14.3-32 32-32l64 0c17.7 0 32 14.3 32 32l0 256 32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 512c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0 0-224-32 0c-17.7 0-32-14.3-32-32z"/></svg>
|
||||
|
After Width: | Height: | Size: 535 B |
@@ -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="M133.8 36.3c10.9 7.6 13.5 22.6 5.9 33.4l-56 80c-4.1 5.8-10.5 9.5-17.6 10.1S52 158 47 153L7 113C-2.3 103.6-2.3 88.4 7 79S31.6 69.7 41 79l19.8 19.8 39.6-56.6c7.6-10.9 22.6-13.5 33.4-5.9zm0 160c10.9 7.6 13.5 22.6 5.9 33.4l-56 80c-4.1 5.8-10.5 9.5-17.6 10.1S52 318 47 313L7 273c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l19.8 19.8 39.6-56.6c7.6-10.9 22.6-13.5 33.4-5.9zM224 96c0-17.7 14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32zm0 160c0-17.7 14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32zM160 416c0-17.7 14.3-32 32-32l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32zM64 376a40 40 0 1 1 0 80 40 40 0 1 1 0-80z"/></svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
@@ -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="M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 929 B |
@@ -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="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376C296.3 401.1 253.9 416 208 416 93.1 416 0 322.9 0 208S93.1 0 208 0 416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>
|
||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 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="M-5 118L23.5 279.7c14 79.5 76.3 141.8 155.8 155.8l12.7 2.2c-16.5-28.6-27.1-60.7-30.6-94.5l-24.1 4.3c-9.7 1.7-18.8-5.8-16.9-15.5 4.8-24.7 19.1-46.6 39.7-60.9l0-74.6c-1.4 .8-3 1.3-4.7 1.6l-63 11.1c-8.7 1.5-17.3-4.4-15.9-13.1 3.1-19.6 18.4-36 39.1-39.7 17.2-3 33.9 3.5 44.6 15.8l0-22.7c0-22.5 6.9-52.4 32.3-73.4 26-21.5 67.7-43.9 124.9-54.2-30.5-16.3-86.3-32-163.8-18.4-80.3 14.2-128 50.1-150.1 76.1-9 10.5-10.8 24.9-8.4 38.5zM208 138.7l0 174.8c0 80.7 50.5 152.9 126.4 180.4L362.1 504c14.1 5.1 29.6 5.1 43.7 0L433.6 494C509.5 466.4 560 394.3 560 313.5l0-174.8c0-6.9-2.1-13.8-7-18.6-22.6-22.5-78.2-56-169-56s-146.4 33.6-169 56c-4.9 4.9-7 11.7-7 18.6zm66.1 187.1c-1.4-7 7-11 12.7-6.6 26.9 20.6 60.6 32.9 97.2 32.9s70.2-12.3 97.2-32.9c5.7-4.4 14.1-.4 12.7 6.6-10.1 51.4-55.5 90.3-109.9 90.3s-99.8-38.8-109.9-90.3zm.5-101.5C281.2 205.5 299 192 320 192s38.9 13.5 45.4 32.3c2.9 8.4-4.5 15.7-13.4 15.7l-64 0c-8.8 0-16.3-7.4-13.4-15.7zM480 240l-64 0c-8.8 0-16.3-7.4-13.4-15.7 6.5-18.8 24.4-32.3 45.4-32.3s38.9 13.5 45.4 32.3c2.9 8.4-4.5 15.7-13.4 15.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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="M468 7c7.6 6.1 12 15.3 12 25l0 304c0 44.2-43 80-96 80s-96-35.8-96-80 43-80 96-80c11.2 0 22 1.6 32 4.6l0-116.7-224 49.8 0 206.3c0 44.2-43 80-96 80s-96-35.8-96-80 43-80 96-80c11.2 0 22 1.6 32 4.6L128 96c0-15 10.4-28 25.1-31.2l288-64c9.5-2.1 19.4 .2 27 6.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 539 B |
@@ -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="M471.6 21.7c-21.9-21.9-57.3-21.9-79.2 0L368 46.1 465.9 144 490.3 119.6c21.9-21.9 21.9-57.3 0-79.2L471.6 21.7zm-299.2 220c-6.1 6.1-10.8 13.6-13.5 21.9l-29.6 88.8c-2.9 8.6-.6 18.1 5.8 24.6s15.9 8.7 24.6 5.8l88.8-29.6c8.2-2.7 15.7-7.4 21.9-13.5L432 177.9 334.1 80 172.4 241.7zM96 64C43 64 0 107 0 160L0 416c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 64z"/></svg>
|
||||
|
After Width: | Height: | Size: 825 B |
@@ -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="M352.9 21.2L308 66.1 445.9 204 490.8 159.1C504.4 145.6 512 127.2 512 108s-7.6-37.6-21.2-51.1L455.1 21.2C441.6 7.6 423.2 0 404 0s-37.6 7.6-51.1 21.2zM274.1 100L58.9 315.1c-10.7 10.7-18.5 24.1-22.6 38.7L.9 481.6c-2.3 8.3 0 17.3 6.2 23.4s15.1 8.5 23.4 6.2l127.8-35.5c14.6-4.1 27.9-11.8 38.7-22.6L412 237.9 274.1 100z"/></svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/></svg>
|
||||
|
After Width: | Height: | Size: 511 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 216C0 149.7 53.7 96 120 96l8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-8 0c-30.9 0-56 25.1-56 56l0 8 64 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64l-64 0c-35.3 0-64-28.7-64-64L0 216zm256 0c0-66.3 53.7-120 120-120l8 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-8 0c-30.9 0-56 25.1-56 56l0 8 64 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64l-64 0c-35.3 0-64-28.7-64-64l0-136z"/></svg>
|
||||
|
After Width: | Height: | Size: 686 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 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="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -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="M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z"/></svg>
|
||||
|
After Width: | Height: | Size: 801 B |