Under the search box there are now three checkbox chips choosing what the page
shows. Songs keep the existing grid and Albums searches the albums by title;
Users is rendered disabled until there is something behind it.
A grid of your albums with a create dialog, and a detail page with the
tracklist, inline editing and a picker that lists the songs you uploaded so
they can be dropped into the album. Covers reuse the playlist mosaic since an
album has no artwork of its own yet.
The checked-in schema was stale and typed artist, description and externalId
as always present, so the code trusted values the API returns as null. The
regenerated schema tells the truth; the API-shaped types now admit null and it
is normalised on the way into the player, where the state stays non-null.
The ytimg variants are 16:9, so picking one by width handed a square tile far
fewer pixels than it needed: a 96px cover got the 120x90 file and kept 90px
after the crop. They are chosen by their short side now, the album art is
preferred over the video frame when the search returns both, and the display
sizes account for the tiles being up to 180px on a 2x screen.
Typing in the search box navigated on every pause, so single letters and
round-trips back to the same term each triggered a full YouTube search and a
new grid of covers. The debounce is longer, a term identical to the current
one no longer navigates, YouTube is only queried past a minimum length, and
the infinite scroll waits until the sentinel is closer before pulling a page.
The browser used to pull every cover straight from Google, so a search grid
plus infinite scroll fired dozens of cross-origin image requests and started
coming back as 429. Covers now go through /api/youtube/thumbnail, which
resizes the upstream URL, caches the bytes in memory, collapses duplicate
in-flight fetches, caps the concurrency against Google and backs off when it
answers 429. Responses are immutable for a month and revalidate with an ETag,
and serving them same-origin also lets the accent extractor read the pixels.
The PUT was always sent to 127.0.0.1, which is nothing inside the container, so
uploads failed whenever the web player did not run on the same host as
SeaweedFS. Take an optional S3_UPLOAD_ENDPOINT instead (the compose stacks
point it at seaweedfs:8333) and fall back to the host in the presigned URL. The
signed Host header is preserved either way, so the signature still matches.
Styles: adds the scrim and on-art tokens so the black overlays and the
white marks drawn over artwork stop being raw tailwind colours, and
routes every component through the semantic layer instead of reaching
into the --mf-* palette. Renames --mf-accent-60, which was actually an
80% mix, to --mf-accent-soft, and removes the unused --mf-radius and
the --color-neutral-950 override.
Dead code: the root +layout.server.ts already redirects everything
except /login, so the anonymous branches in the home and explore loads
and the needsAuth empty state could never run. Deletes the unused
accentSoft helper, and stops exporting helpers nobody imports.
fmtTime and fmtDate move out of theme/color.ts into $lib/format.ts;
they are not colour concerns.
The accent animation now cancels its pending frame on teardown.
Updates the compose build context, the Dockerfile header, the package
name and the docs. The compose service and container keep the name
frontend/musify-frontend, since nginx routes to http://frontend:3000.
The source files also carry the first prettier pass, so the rename and
the reformat land together for them.