Commit Graph
21 Commits
Author SHA1 Message Date
logan 65c1b4fd53 fix(a11y): move the card grids by a row, not to the end
Build & publish Arch package / arch-package (push) Successful in 2m6s
CI / check (push) Successful in 2m28s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m6s
`RovingGridController.measureColumns` read `offsetTop`, and every card
in these grids is drawn by a `lit-virtualizer`, which positions its
children with a transform — which `offsetTop` does not see. So all of
them reported 0, every rendered card counted as one row, and ArrowDown
was `min(i + everything, last)` while ArrowUp was `max(i - everything,
0)`: the vertical arrows have been End and Home in the albums, artists
and genres grids since the day this was written. At 700x700 with three
real rows of 3/3/2, ArrowDown from card 0 landed on card 7.

Two things behind it, both only visible once the grid splits:

`cover-grid`'s scrollToIndex was `querySelector('lit-virtualizer')` —
always `#grid-before` — while the roving index spans the whole album
list, so with a dropdown open End scrolled the wrong half to an index
it does not contain. It now picks the half that holds the index and
rebases it.

And the focus is retried on a deadline rather than taken once at the
host's `updateComplete`: a scroll of 5 000 rows produces the card a few
hundred ms later, so the tab stop moved and nothing took focus, which
looks exactly like the key not being handled.

Also waits for the virtualizer in album-dropdown.spec's expandCard,
which flaked on roughly one run in two on main.
2026-08-12 17:44:22 -04:00
logan 287b6445fa fix(a11y): give every wa-dialog an accessible name
CI / check (push) Successful in 2m53s
Search index maintenance / maintain-index (push) Successful in 6s
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / e2e (push) Successful in 4m47s
Eleven dialogs passed a `label` that never reached the accessibility
tree: Web Awesome renders it into an <h2 id="title"> in the same shadow
root as the native <dialog> and never points aria-labelledby at it, so
getByRole('dialog', {name}) matched nothing and a screen reader
announced an unnamed dialog. a11y.md lists all of them under "what is
already correct".

utils/name-dialog.ts sets the IDREF, with aria-label as the fallback for
without-header (first-run-wizard), called from each host's updated().
aria-labelledby rather than aria-label because three call sites compute
their label at render time, and the heading re-renders anyway. It waits
for the dialog's own first update: wa-dialog populates its shadow root
in its own update, so a query at the host's firstUpdated names nothing.

Reaching into another library's open shadow root is deliberate and the
failure is bounded — if the structure moves, the query misses and the
dialog is as unnamed as it was.
2026-08-12 14:52:23 -04:00
logan 1ed4167634 feat(a11y): give the context menu a keyboard, and the app a voice
The context menu was the only route to Play, Add to Queue, Play Next,
Add to Playlist, Favourite and Track Details, and it opened on
right-click alone: the panel had no role=menu, so its six menuitems were
orphaned, nothing moved focus into it, and nothing handled arrows or
Escape (a11y.3). Phase 1 deferred this deliberately so it would land
with the dialogs, as one focus-management implementation.

MenuKeyboard is that model. It is standalone rather than part of
ContextMenuController because playlist-view renders a menu without the
controller, and the only thing worse than a menu with no keyboard model
is two menus with two of them. Shift+F10 and the ContextMenu key open it
from a focused row, anchored to that row, and focus returns there.

Three lists had no focused row to open it from, so they gained a roving
tab stop (utils/roving-rows.ts, written once rather than three times).
track-list keeps its own: it predates this, carries selection semantics
the other three do not have, and is pinned by its own tests.

Also the ARIA tail this is one story with: aria-sort on the column
headers (role=columnheader arrived in Phase 1 without it), listbox and
option on the four selectable grids — aria-selected on role=button is
invalid and was being dropped, so the state the whole ctrl/shift
interaction exists to produce was invisible — and live regions on the
four async surfaces that changed in silence.

Two things a reproduction taught that reading could not: the
wa-dropdown-items have not set their role when the host's updateComplete
resolves, so querying by role then finds nothing and the menu opens
without taking focus; and focus() on a popup that has not positioned
itself is a silent no-op.
2026-08-12 11:07:34 -04:00
logan 7d9e0bf2fb perf(frontend): patch the stores instead of invalidating them
An event carries what a consumer needs so it never has to invalidate.

- `library-store` answers `TrackPlayCountChanged` by patching one
  track, replacing the tracks array (consumers key memoized caches on
  its identity) while sharing every unchanged Track — instead of
  discarding four collections and refetching 25 MB per song.
- `playlist-store` answers `PlaylistTracksChanged` by refetching the
  one playlist the event names, plus the summaries, since `UpdatedAt`
  is a sort key. 2 668 kB and 172 ms for one heart, against 2.0 kB. It
  falls back to a full invalidate only where a patch cannot be shown
  to be equivalent: no id, a cold cache, an unknown id, or a fetch
  already in flight. And a store with no subscriber fetches nothing —
  the singleton's constructor used to put every track of every
  playlist on the path to first paint for a view the user might never
  open.
- `library-store` guards every fetch with a cache generation and holds
  the request itself instead of deriving a promise from subscriber
  notifications, which fixes the library-filter race and the
  never-settling waiter together: they are the same bug seen from
  either end.
- `explore-cache`'s two art caches are bounded, sharing one exported
  cap constant — the artist photo's data URL is held by both, so
  capping either alone frees nothing at all and reads as a fix that
  did not work.
- `search-store` deliberately does *not* coalesce its notify: deferring
  makes a subscriber that unsubscribes synchronously after a `setTerm`
  miss the notification entirely, which is a semantic change rather
  than an optimisation, and this is the store on the keystroke path.
- `selection-controller` retains its keys across a refetch rather than
  clearing them, since they are file paths and those survive one, and
  `getSelectedKeysOrdered()` gains an early exit. It stays a walk of
  the list: an index goes stale on any re-sort, re-filter or refetch
  while a file path survives all three, and 3 ms does not buy a
  silently mis-ordered queue insert.
2026-08-12 01:19:04 -04:00
logan 5fb9a0d246 perf(frontend): add the bound, the lookup and the lazy dialog
Four small modules the views below adopt:

- `lru-map.ts` — a Map re-inserted on read and trimmed from the front.
  `explore-view` never unmounts and its two art caches were plain
  Maps: twenty-four searches retained 20.58 MB and were still
  accelerating, a cover thumbnail being ~27 kB of base64 and an artist
  photo ~128 kB.
- `cache-stats.ts` — a bound has to stay checkable, so caches register
  and `window.__yjCacheStats()` reports entries, retained chars and cap
  in one eval, rather than the next session having to rebuild the
  twenty-four-search reproduction first.
- `track-index.ts` — a WeakMap from the tracks array's identity to a
  Map<FilePath, Track>. 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 3.0 to
  6.3 s. 68 ms after. Keying on the array's identity is safe for the
  same reason the memoized filter caches are, and it is collected for
  free when the store drops the array.
- `lazy-track-details.ts` — one memoised dynamic import, because
  `track-details` (42 kB) was imported for side effect by all five
  components that open it and so was evaluated before first paint
  however the routes were split.
2026-08-12 01:18:48 -04:00
logan fbf1eff8f6 feat(frontend): give failure one voice
There was no app-level notification surface: two components had grown
private toasts and the other 84 catch blocks ended at console.error,
so a user with a moved file, a locked database or an offline network
saw a button that did nothing. Where errors did surface, eight sites
printed the raw Go string.

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 (data at risk), Persistent (something
asked for that did not happen, worth retrying), Transient (a small
action whose state visibly reverted anyway), Inline (rendered in the
panel that failed).

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, so the
app-level stack sits under the header — the player's own floating
notice grows upward by however many lines it needs.

`utils/describe-error.ts` maps the causes a user can act on to copy;
`explainError` repeats a backend message when it is one of our own
sentinels rather than a Go wrapping chain, since mapping "a library
with that name already exists" to something generic is a regression.
`confirmAction()` is a wa-dialog, so destructive actions inherit the
focus trap and Escape the hand-rolled overlays do not have.
2026-08-12 01:18:34 -04:00
logan 7acb197daf feat(frontend): give a cached view a lifecycle and a keyboard owner
`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.

- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
  `viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
  and `whileActive` torn down on the way out, and an off-screen view
  that does not render. `registerViewAware` gives a shared reactive
  controller the same treatment, because a controller cannot know
  whether its host is a cached view — `ContextMenuController` bound
  three document listeners in `hostConnected`, which for a cached host
  is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
  scope from focus alone was not enough: this app is driven with the
  mouse, focus sits on `<body>`, and a focus-only rule would have made
  the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
  button, select, slider, checkbox, menu, grid row, or anything inside
  an open dialog — so the unmodified single-key bindings stop stealing
  Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
  arrows, since a card per tab stop makes a library-length tab
  sequence.
2026-08-12 01:18:34 -04:00
logan 7c3c0e25b9 fix(ui): make every track, album and artist name navigate somewhere
A name linked only when the entity carried an MBID — and for tracks,
only when it carried two. That rule is invisible, so a track list read
as randomly broken: some titles were clickable, most were not, and
nothing on screen said why.

A name now always goes somewhere. Tagged entities open their
MusicBrainz page as before; untagged ones open the *library* page for
the same album or artist, which both detail views already support via
a local id — they just had no caller passing one. An untagged track
highlights by title, since a recording MBID is exactly what it lacks.

Links now fire on a genuine single click only. Every list these appear
in also plays a row on double-click, and the title is the widest thing
in the row, so the first click of that gesture lands on the link:
navigating immediately meant double-clicking a track title opened a
page instead of playing it, which the e2e playback suite caught. The
navigation is held for one double-click interval and dropped if the
second click arrives, while the dblclick itself is left to bubble to
the row — so rows do not need to know links exist.
2026-08-11 01:15:00 -04:00
yonluandClaude Opus 4.8 65048401e8 feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:14:20 -04:00
yonlu d5140395da wip on autotagging 2026-05-01 11:52:50 -04:00
yonluandClaude Opus 4.6 93892c10de wip(explore): library-only mode, ranked search, UI polish — as-is
End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:57:00 -04:00
yonlu f5677628ef feat(16-01): add selectAll() to SelectionController and dispatch shortcut:select-all event
- Add selectAll() method to SelectionController that selects all items via host interface
- Change app.selectAll dispatch from document.execCommand('selectAll') to CustomEvent('shortcut:select-all')
2026-03-07 10:18:08 -05:00
yonlu 768a8cf1f2 replaced track info string map with a struct 2026-02-24 15:39:54 -05:00
yonlu 1221a403cf cover grid refactor
-split component into several files
2026-02-24 09:49:34 -05:00
yonlu 16060023bb audio file info added, fixed right click selecting. 2026-02-23 19:05:57 -05:00
yonlu d89dee44e3 single track drag-and-drop ghost 2026-02-23 09:42:11 -05:00
yonlu 7bc85af5f4 fixed cover-grid selection and queuing behavior, added "clear queue" button 2026-02-20 13:16:13 -05:00
yonlu 56cf92a44c fixed drag and drop behavior 2026-02-19 22:37:19 -05:00
yonlu 193f65bd98 basic drag-and-drop, fixed end of scan behavior 2026-02-19 15:25:49 -05:00
yonlu 026ab1c333 adjusted frontend event handling to optimize json payload size for different events, fixed incorrect behavior when currently playing track is removed from queue 2026-02-18 00:49:29 -05:00
logan d78c0584e2 Squash merge audio-player-component into main 2026-02-13 20:39:23 -06:00