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.
Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.
<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.
The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.
Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
A list pays per row, and only while scrolling — and none of this is
visible to any test tier: nothing renders differently and nothing
fails, the app is just slower.
- The track list's Art column rendered `CoverArtPath`, the original
artwork, into a 24 px box while `CoverArtSmall` sat unused on the
same model, with no `loading="lazy"`. 26 of 26 image requests asked
for the full-size tier; now 0.
- `artists-view`'s avatar fallback linear-scanned every cached album
per card per frame, lowercasing two strings per comparison, inside
the virtualizer's renderItem — the common case, since a locally
tagged library has no artist images at all. Measured at 5 000 albums
and 24 visible cards: 1.46 ms/frame -> 0.01 ms/frame.
- Five components resolved selected file paths back to tracks with
`tracks.find(...)`; they share `utils/track-index.ts` now. "Select
all -> Edit tags" at 50 000 tracks: 3 051-6 298 ms -> 68 ms.
- "Play this artist", "play these albums" and the album drag cache
resolve paths in one call instead of one per album.
- The column-resize drag registers its document listeners on mousedown.
Two things here are load-bearing and read as sloppiness. The per-render
arrow functions in `artists-view` and `genres-view` are the *only*
thing changing a property of their virtualizer on a host update, and
therefore the only thing repainting the cards: hoisting them to stable
fields takes a selection from 1 highlighted card to 0. And 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.
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>
Artists without dedicated images (from fanart.tv, TheAudioDB,
Wikidata, etc) now fall back to their most popular album's cover
art in the library artist grid. Uses the appropriate size tier
based on device pixel ratio — CoverArtSmall for small avatars,
CoverArtMedium/Large for larger ones. Only letter-initial
placeholder remains as the absolute last resort.
Views with contain:layout in shadow DOM need explicit height:100%
on :host so their internal flex layout fills the parent's flex
space. Without it, the component doesn't know its height and the
internal content doesn't stretch to fill the panel.
Added height:100% to: cover-grid, artists-view, genres-view,
playlist-view, track-list, config-page (also box-sizing).
explore-view and detail views already had it.
Complete rewrite of the artist image pipeline:
STORAGE:
- Migration 16: artist_images table tracking source, URL, path,
primary flag, dimensions per image (up to 10 per artist)
- Directory structure: artist-images/{mbid[:2]}/{mbid}/ with
primary.jpg + primary_sm.jpg/_md.jpg/_lg.jpg thumbnails
- Miss marker (.miss file) prevents re-fetching artists with no image
SOURCES (priority order):
1. MusicBrainz direct image relations (Wikimedia Commons)
2. Wikidata P18 property (Wikimedia Commons)
3. Wikipedia lead image (NEW — via Wikidata sitelinks → Wikipedia API)
Each source is checked, deduplicated, and the first available
image becomes the primary with sm/md/lg thumbnail generation
(100px/200px/400px, matching cover art tier sizes).
ASSET SERVING:
- /artist-images/ path registered with Wails asset handler
- Serves files via http.FileServer from the artist-images directory
- Same pattern as /covers/ for cover art
ARTIST MODEL:
- Artist struct gains ImageSmall/ImageMedium/ImageLarge fields
- resolveArtistImages does bulk MBID lookup → disk stat for each
- Populated in GetAllArtists and GetAllArtistsByLibrary
GRID VIEW:
- artists-view uses model URLs directly (no more base64 data URLs)
- Size selection based on imageSize * devicePixelRatio (like cover-grid)
- Removed batch GetArtistImages call and in-memory cache — no longer needed
Replace per-artist sequential GetArtistMBID + GetArtistImageURL
calls (2 Wails round-trips × N artists) with a single batch
GetArtistImages(names[]) call that:
1. Resolves all names → MBIDs via AllArtistMBIDs() (one DB query)
2. Checks disk cache for each MBID via GetCachedImage (no network)
3. Returns map[name]→dataURL in one Wails bridge round-trip
Only returns already-cached images from the disk cache populated
by the index build. No network fetches triggered — artists whose
images haven't been cached yet keep the initial letter fallback
until the index build resolves them in the background.
Result: all cached artist images appear simultaneously on first
render instead of loading one-by-one over several seconds.
The local artists grid now shows Wikimedia artist photos in the
circular avatars. Each visible artist card triggers an async load:
GetArtistMBID(name) → GetArtistImageURL(mbid) → cached data URL.
Images load progressively — the initial letter placeholder shows
immediately, replaced by the photo when it resolves. Results are
cached in-memory per session. Artists without MBIDs or without
Wikimedia photos keep the initial letter fallback.
Uses the same disk-cached artist image pipeline as the explore
views — no extra network requests for previously resolved artists.
Targeted optimizations for the DMABuf-disabled rendering path where
every frame is software-composited:
- Replace infinite CSS scroll-text animation with transition-based
cycle that only repaints during active scroll, not during pauses
- Remove CSS mask-image on scrolling text (mask + animation was the
single most expensive continuous repaint)
- Replace wa-icon in track rows with inline SVG — eliminates 30-50
shadow DOM trees (each with SVG fetch/parse) during scroll
- Remove hover transitions on album cards, artist cards, genre cards,
fav icons, queue remove buttons — each transition was causing
per-frame software repaints
- Use visibility:hidden instead of opacity:0 for queue remove button
(binary switch vs per-frame alpha blend)
- Add decoding=async to now-playing cover art images (prevents
main-thread blocking during image decode on track change)
- Add contain:strict to fixed-height track rows (33px) and queue
items (49px) — browser skips size contribution calculations
Root causes addressed:
- track-list had no _itemSize hint for flow layout — virtualizer
defaulted to 100px, measured actual ~33px rows, then called
_correctScrollError/scrollTo on every scroll causing visible jumps
- will-change:transform on virtualizer elements caused nested GPU
layers (virtualizer positions children with transforms internally)
adding compositor overhead instead of helping
- content-visibility:auto on album cards conflicted with virtualizer's
own DOM recycling, causing redundant layout recalculation
- track-list visibilityChanged handler wrote to store synchronously
on every event (per-item during scroll) without any throttling
- IIFE closure in renderTrackRow created a new function per row per render
Fixes applied:
- Add _itemSize:{height:33} + fixed height:33px on .track-row (matches
queue-panel pattern that already worked smoothly)
- Add overflow-anchor:none on track-list virtualizer
- Remove will-change:transform from all 6 scroll containers
- Remove content-visibility:auto from album cards
- RAF-throttle visibilityChanged scroll position saves
- Replace IIFE with direct cols.map() in template
- contain: layout style on :host of all 6 scroll-heavy components
- contain: paint + will-change: transform on all scroll containers for GPU compositing
- content-visibility: auto + contain-intrinsic-size on .album-card for off-screen skip
- cover-grid, track-list, queue-panel, artists-view, genres-view, playlist-view
Move search indicator from absolute-positioned overlay into sort toolbar
(or dedicated search-bar-row for artists/genres views). Shows indicator
on empty-state screens. Fix cover art not displaying for expanded album
tracks by checking expandedAlbumId before the albumName guard. Add
ScanWarning model bindings.
repeat() as child content of lit-virtualizer bypasses virtualization,
rendering ALL items instead of only visible ones. This caused 2+ minute
loading times and UI freezing with large libraries.
The correct approach: use lit-virtualizer's native .renderItem and
.keyFunction properties which integrate with its scroll-based viewport
management.
- cover-grid: all 3 virtualizers (single, before-split, after-split) use repeat() with album.ID key
- artists-view uses repeat() with artist.ID key
- genres-view uses repeat() with genre.name key
- Removed gridKeyFunction (now inlined in repeat calls)
- Removed .renderItem and .keyFunction properties from all virtualizers