f18691560d9475b5dbb0bd89e88baffc76155d68
314
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f18691560d |
fix(shell): publish the active view, so both navs follow the back path
The nav components learned where the user was from the `navigate` CustomEvent, which only the outbound path dispatches: `popstate` calls `handleNavigate()` directly. So a back-navigation left both of them highlighting the view just left — desktop included, at any width, on any back across two primary views. Opening a detail view was the same cause wearing a different symptom: `app-sidebar` guarded on its own item list and kept its highlight, `bottom-nav` did not and lit nothing. It cannot be fixed by re-dispatching `navigate` — `index.ts` is that event's document listener, so that is an infinite loop, and "please go to X" is not the statement being made. `activeViewStore` is the shell saying "the active view is now X", once per navigation, `popstate` included; both navs read it through a controller and hold no `activeView` of their own. A store rather than an event because a component that mounts *after* a navigation still has to know: `bottom-nav`'s drawer builds its `app-sidebar` on open, and that copy had heard nothing at all, so the drawer opened on Home from any page in the app. Closes #72 |
||
|
|
f967916550 |
fix(page-header): collapse the actions that do not fit into a menu
Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain-<button>/<wa-button> split between them onto one shape the header styles — and lets it measure a button that has already upgraded, rather than a wa-button whose shadow DOM arrives in its own first update. Four things in it are load-bearing: - Every measuring pass starts from all-visible, so the collapsed set is a pure function of the current width and an action comes back when the window grows. It flips `hidden` imperatively rather than re-rendering between steps, or the intermediate state paints and the fix flashes the overflow it exists to prevent. - "Fits" means nothing is clipped, not that the header does not overflow. Once the title can ellipsis it absorbs the pressure and scrollWidth reports a perfect fit while the heading reads "Playlis…" — this bug moved from the button to the title, and invisible to the same measurement that missed it the first time. - New Playlist has the highest priority because it is the drop target and a closed menu cannot be one. `PageAction.drop` therefore carries the host's own handlers; the affordance is absent from the overflow rather than approximated there. - The overflow trigger is a named button with aria-expanded and an aria-controls naming a panel that is always in the DOM, and the keyboard model is the shared `MenuKeyboard`. `layout-overflow.spec.ts` passes on the broken build — it asserts the shell needs no sideways scrolling, and clipping inside a component is invisible to it, which is why this defect survived a spec named for it. The new spec measures each button against its own header at four viewports and asserts buttons plus menu account for every declared action, without which it would pass vacuously on a build rendering none. Closes #69 |
||
|
|
ff3c4003cb | Merge branch 'fix/61-mini-player-plain-text' into fix/quick-wins-batch | ||
|
|
c518ac8c73 |
feat(now-playing): plain text instead of links in the phone mini player
CI / check (push) Skipped
CI / e2e (push) Skipped
The bottom bar's title, artist and "Playing from X" all navigate. In a bar sized for a bar they are a few characters of text, which is not a touch target — and explore-link holds its navigation for one double-click interval and drops it if a second click arrives, a gesture that exists so double-clicking a row can play it and that means nothing on touch. Below the shell's phone breakpoint the three render as plain text. The words are unchanged: the source line still says where the queue came from, because dropping the link is the change and dropping the information would be a different and worse one. The cover art already carries the phone-only button that opens the full-screen Now Playing view, which is where the links live. This is in JS rather than in the stylesheet because what changes is the content, not its appearance — no CSS rule takes a click handler off an element. matchMedia is read in connectedCallback for the reason the reduce-motion query beside it already is, so a test can answer it first. Two smaller things. PHONE_QUERY moves out of track-list.ts into utils/breakpoints.ts: it was a private const when one component needed it, and a second reader is where a copy starts drifting from index.css. And `phone` joins geometryKey(), because crossing the breakpoint swaps a link for a bare string and the marquee travels a distance read from measuring it — the words being identical either side is not the same as the box measuring the same. Closes #61 |
||
|
|
977f624123 |
fix(home): gate the card play button on the device having hover
CI / check (push) Skipped
CI / e2e (push) Skipped
The play button on a home shelf's cover cards is revealed by :hover, and a touch long-press synthesises a hover state in the WebView — so on a phone it flashed into view during the 500ms hold that utils/long-press.ts is measuring for a context menu. A control appearing because the user was reaching for a different one. It is gated on `(hover: hover) and (pointer: fine)` rather than on width, so it is absent on any touch device and present on a desktop with a small window. A phone user taps the album and plays from the detail view, so nothing replaces it. The default outside the query is display:none, not opacity:0. An opacity-0 button still takes taps and is still in the accessibility tree, so leaving the reveal as the only guarded part would keep the hit area for a control the phone can never show. The test asserts the parsed stylesheet rather than rendering as a phone, and says so: CDP's Emulation.setEmulatedMedia does not reach this tier's iframe, so matchMedia still answers `hover: hover` after it is set. The regression worth catching is someone hoisting the rule back out of the query as a tidy-up — a change no desktop assertion can see. Closes #68 |
||
|
|
4025106234 |
fix(queue): overlay the content instead of taking its width
The panel is flex-shrink: 0 in the flow of .content-area, so an open queue was paid for by the main panel rather than covering it. Measured on Playlists: 379px of content left at 900x600 with all three of the page header's actions clipped, 69px at 390px, and 0px at 320px — where the content was not degraded but gone. It goes to an overlay with a scrim when the content cannot spare the width, and the rule is computed rather than breakpointed: `available - panelWidth < 480`, where available is .content-area's width and so already accounts for the sidebar's collapse at 900. A media query cannot express this, which is the reason for the property: the panel is drag-resizable between 200 and 500px and persisted, so a viewport breakpoint silently assumes the default 320 and is wrong by up to 180px for a user who widened it — in the direction that hurts, since a wider queue is exactly when the content can least afford it. 480 is a judgement and the comment says so: there is no cliff to derive it from (the track list rescales continuously, 213px to 124px columns with no row overflow), so it is anchored to keep the default 1100px window inline while putting every measured-broken case on the overlay side. The overlay is a presentation and not a fork — #55 asks for one component with two mount points — so the roving tab stop, Alt+Arrow reorder, drag reorder and selection semantics are untouched. Escape closes it and returns focus, attached only while the overlay is up: it is a dismissal rather than a shortcut, which is why it is not a panel-scoped binding. The scrim covers the content area only, not the sidebar or the transport, because the queue is not modal. Refs #24 |
||
|
|
b5d70ac1cd |
feat(explore): offer the autotag match on the album page
The complaint was having to notice the metadata was missing, then go and hunt the album down on the Autotag page. The album page now says it while you are looking at the thing: "MusicBrainz has a match for this album: <release> by <artist>", with Apply tags and Review in Autotag. Four things about it are load-bearing. **Applying is offered only where it would do the whole album.** A tagging group is a folder, so a multi-disc album is several, and one button that applied to the best-scoring group would leave the album holding a mix of old and new tags — the exact case the app's Blocking notification level exists for. `groupCount` is the test, and the answer there is review rather than apply. **It rewrites files, so it asks.** `confirmAction()` with an impact line that says it cannot be undone and that nothing is moved or deleted, because "rewrites your files" reads worse than it is. The apply goes through `ApplyAsync`, the registered-job path, so progress belongs to the jobs indicator and this page does not grow a second one — what it owes the user is the acknowledgement, because the button is here. The suggestion clears itself on success rather than inviting a second click while the job runs. **The banner does not quote a percentage.** The backend has a score and deliberately keeps it out of the sentence: 0.95 reads as a probability and is not one. Which release it is, is the part a person can judge. **"Review in Autotag" lands on that album.** The queue is sorted by score so the intended folder is often near the top, and "often" is a link that sometimes opens a different album. Autotag is a cached primary view, so there is no construction to hand a payload to: the request goes on as an attribute and the view *consumes* it, or every later visit would reopen a folder the user finished with long ago. `ICON_AUTOTAG` joins the vocabulary at the same time, on the rule `ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a suggestion pointing at Autotag wears the Autotag destination's own mark. It was written inline in the sidebar; two call sites is where a name stops being one component's detail, so the sweep governs it now. Verified against the running app with a staged match: the banner, the confirm dialog's wording, and the navigation landing on the right folder with the attribute consumed. Closes #28 |
||
|
|
21b303ba7c |
fix(ui): stop a closing dialog answering the next question
`confirm-dialog` is one singleton for every confirmation in the app, and `wa-dialog` reports its close asynchronously: `open = false` starts an animation and `wa-hide` arrives after it. So a hide belonging to a question already answered can land after the *next* question has opened, and cancel it — the user is asked something, the dialog vanishes on its own, and the call site is told they said no. Each ask now carries an id. `close` ignores an id that no longer names the question on screen, the button handlers pass none (they always mean the current one), and only the `wa-hide` handler carries one, because only `wa-hide` can arrive late. Found by writing two `confirmAction()` tests in one file: the second could not be accepted at all, because the first one's hide had cancelled it before the click landed. Reaching it in the app needs two confirmations close together, which the album page's "Apply tags" makes possible. |
||
|
|
905654cc84 |
feat(explore): demote the album page's version selector to a disclosure
Choosing which pressing you are looking at is an advanced, metadata-repair task, and it sat directly above the tracklist with a heading, a `<select>` and a paragraph explaining how our clustering picks a "standard version" by weighing release count, status and date. That is a sentence about our own heuristic in the most valuable space on the page. It is now "Other versions of this album (N)" below the tracklist: a real `<button aria-expanded aria-controls>` inside the heading that names the section, with the body rendered unconditionally and toggled with `hidden`, because `aria-controls` has to name an element that is in the DOM. Both rules are `config-section`'s rather than new ones. It is demoted, not removed — matching the wrong release is a real problem and this is how it gets fixed. **Two more blocks shared that slot and neither was guarded.** The selector at least had `distinctTracklistCount() <= 1`; the `Versions / Loading releases…` spinner and the `Versions / <error>` block did not, so both took the primary position on every album regardless of whether there was ever going to be a choice. The spinner said what `renderTracklist` was already saying about the same fetch, so it is gone. The error was the one `catalog-scope-notice` shows at the top of the page with a retry — every path that sets `errorReleases` also sets `catalogFailed`, the only route to `unavailable`. That error is what made this a rewrite rather than a move. `renderTracklist` returned `nothing` on `errorReleases` and leaned on the selector's own block to have said it, and a control inside a collapsed disclosure cannot be a page's error surface. The failure belongs to the list that is missing because of it, so that is where it is drawn. **What must not be lost is which version is on screen.** The default is what the header already describes, so saying it on every album would be this issue's own complaint one size smaller. `defaultVersionKey` is the test: a line appears above the tracklist only once someone has chosen another, naming it and offering the way back. The ★ and the words "in your library" survive unchanged inside the panel, and the panel does not close when the selection changes — a panel that shuts on use cannot be used twice. The `<select>` also loses an `aria-label` of "Select release version" that outranked its own visible `<label>Version</label>`, which is a label not in the name. Verified against the running app as well as the suite: the collapsed page, the open panel, a chosen version and 390px width all read correctly, and the shell still measures 390 in a 390 viewport. Closes #17 |
||
|
|
10eca353ab |
fix(explore): gate playback on the same answer the row is drawn from
Two play paths still accepted `inLibrary`, so a row drawn dimmed and `aria-disabled` by the new rule would still attempt to play and fail with "this track could not be found in your library" — the disagreement this pass exists to remove, one layer down from the badge. |
||
|
|
88fc50afb8 |
feat(explore): mark what is not owned, everywhere it can be shown
`explore-album-details` had the rule right for one tracklist and nothing else did: Explore's cards, `top-results-row` and the artist page's three card shapes all mixed owned and unowned with a small badge as the only difference, and drew a green tick on the *common* case — which is the treatment that tracklist's own green ticks were removed for. `utils/ownership.ts` is the rule written once, so eight call sites stop each holding their own version: - owned is plain, and draws no badge at all; - unowned is dimmed *and* says so in its accessible name, because dimming is a colour and cannot be the only signal; - a partly-held album says how partly. **Ownership is a file, and `localId` is the flag that says so.** The album page answers with `filePaths`, a real file per displayed track; a card grid cannot afford that and does not need to, because `local_*_id` is built by queries that all join `audio_files` and cleared by a prune whose existence test is a file test in every case. `inLibrary` is written by the same pass, so the two agree in a healthy database — but it is a one-way ratchet (`MAX(in_library, excluded)`) whose only clearing pass is gated on a non-null local id, so it cannot be un-set on its own. Where they already diverged was the client. Both `explore-view` and `explore-artist-details` kept a `libraryMBIDs` set that accumulated every MBID ever seen with `inLibrary` and cleared it never, in views that never unmount. Both are deleted. And one card answered the question twice and got two answers: `renderReleaseMenuItems` gates Play on `localId > 0` while the badge and `albumTarget.owned` used `inLibrary`, so an album with the flag and no local row drew a tick saying it was in your library, offered no Play, and — the request item being gated on *not* owned — offered no way to ask for it either. The count comes from `completenessStore`, shaped like `credit-store`: `request()` is per-card and coalesces a screenful into one `GetAlbumsCompleteness`, absence is cached as an answer, and the whole cache is dropped on a scan, a retag or a removal rather than aged. `aria-disabled` goes on rows that cannot be activated and deliberately not on cards: an unowned card still navigates to the catalog page for it, which is a perfectly good thing to do with something you do not own. Audited and unchanged: `home-view`, `downloads-view`, `cover-grid`, `artist-details` and `genre-details` cannot show catalog content, so everything on them is owned and "owned is plain" is already what they do. The album page's own header badge stays, because that page is about one entity and the badge is its answer rather than a mark on one of many. Closes #38 |
||
|
|
19c68d73a7 |
fix(ui): keep the count in a partial badge that can act
A control is named after what activating it does, so an actionable badge said "Request album X" — and `partial` is actionable, because an album you hold nine of twelve tracks of has three left to ask for. That made the one state the ring exists for the one state whose name did not mention it. The argument the `partial` branch already carries does not stop applying because the badge became clickable: a ring says "some" to a sighted user and nothing to anyone else. The name is now the action and the count. |
||
|
|
89882b4863 |
refactor(ui): give the icons one vocabulary and sweep the call sites
`plus` meant "add to the queue", "add to a playlist", "make a new playlist" and "you do not own this" -- the first two adjacent in the same context menu, so two neighbouring items were the same glyph doing different things. `list` meant the queue (the button that opens it), the Playlists destination, and adding to the queue in `queue-panel` alone. Two icons carrying seven meanings is not a vocabulary, and nothing catches it: a wrong-but-real icon renders perfectly. `utils/icon-language.ts` is the table, beside `library-status.ts` as the issue suggested. The rule it is built on is that an icon names the **noun** it acts on, not the verb: "add to queue" and "add to playlist" are one verb on two nouns, so the noun is what differs -- which is why adding to a playlist wears the Playlists destination's own icon, and why the queue took `bars-staggered` and stopped wearing Playlists'. `plus` keeps the one meaning it is unambiguous about, making something that is not there yet, which covers New Playlist and the drop zones. `bars-staggered` is the only new glyph, vendored through names.txt and fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1. Two things this found rather than changed: - The request toggle's outline/solid pair was already in the app and already right -- `explore-album-details`'s "Request this" button has used `regular/bookmark` -> `solid/bookmark` since it was written -- while the badge forty pixels away showed a **plus** for the same state. That is `utils/library-status.ts`'s fault one layer down: it made the two surfaces agree on what wanting *means* and left them disagreeing on what it looks like. - `explore-artist-details`'s Follow button was `bookmark-check`, which is Font Awesome **Pro** and has never been bundled, so it has drawn the missing-icon fallback -- a circled question mark -- for every followed artist since it was written. `requested-badge.spec.ts` was written for exactly this bug on the album button and says so in its docstring; this is the same bug one component over, still live, because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec had ever followed an artist. So the test does what reaching the state cannot. `icon-language.test.ts` reads every `src/**/*.ts` as raw text and fails on a governed name written outside the table, and separately asserts every `ICON_*` is a *bundled* name -- which is what makes a Pro name a failing test rather than a runtime report from a state something has to reach first. Its first assertion is that it read any source at all, because a sweep over an empty glob passes. `chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned the badge's glyphs against the vocabulary they were meant to follow; it names them from the table now, and keeps the assertion that the three differ, which is the property the states actually need. Downloads keeps the solid bookmark on purpose. That is one word twice, not two words: the badge says the entity is on your list and the nav item is that list. Closes #34 |
||
|
|
aa59773d22 |
feat(explore): let the album page be asked for the whole tracklist
An album the user holds part of showed only the tracks on disk, with nothing to say the rest existed. The page could already draw the full release with the missing rows dimmed -- it just could not be asked: the automatic rule fires on `completeness.known`, which depends on the files declaring a per-disc total, or failing that on the catalog's own `total_tracks`. Neither reaches most albums. #16 fixed the first input for anything tagged from now on, and the second is worse than it looks: the published artifact is from 2026-08-10 and the column landed on 08-16, so `completenessAnswer()`'s catalog fallback answers 0 for every user until the index job republishes. Measured, and noted on #88, which is the publish that carries it. So the control is explicit. A "Show the whole album" switch flips the synthetic "Your Library" entry between the local files and the release, which is the same rendering, reached deliberately rather than inferred. Three things about it are load-bearing: - `showFullTracklist` is a tri-state, `null` meaning "follow the automatic rule". The rule is right when it fires, and the switch has to agree with the page it is sitting on rather than starting out contradicting it -- a plain boolean would need its default recomputed every time the completeness answer moved underneath it. The user outranks the rule in both directions. - `fullReleaseCluster()` falls back to the highest-scoring cluster. `findLibraryCluster` is a guess over the `inLibrary` flags and returns nothing at all when none are set, which is exactly the untagged library this exists for -- without the fallback the control would be absent precisely where it is needed. The sublabel names the release either way rather than leaving the user to wonder whose tracklist they are reading. - It appears only where it can change what is on screen: against the library entry, with a release to switch to, and only when the two tracklists differ. A complete album's release has the same rows as its files, so the switch would redraw the same list and read as broken -- the same test the version dropdown one section up already answers. The accessible name is asserted rather than assumed, through the browser's own computation. `wa-switch` happens to get it right, and for a third reason again: its `<input role="switch">` sits inside a native `<label>` that also holds the `<slot>`, so the name is computed across the flattened tree from light-DOM text. This app has shipped the opposite twice. Closes #7 |
||
|
|
887a9324b4 | Merge remote-tracking branch 'origin/fix/drag-count-badge' into integration/small-fixes | ||
|
|
fcb484ead5 | Merge remote-tracking branch 'origin/fix/album-card-year' into integration/small-fixes | ||
|
|
48de41cd69 | Merge remote-tracking branch 'origin/fix/album-tracklist-heading' into integration/small-fixes | ||
|
|
66a6ee63ab | Merge remote-tracking branch 'origin/fix/seek-bar-clock-width' into integration/small-fixes | ||
|
|
10660c8168 | Merge remote-tracking branch 'origin/fix/wanted-without-client' into integration/small-fixes | ||
|
|
441b67daaa | Merge remote-tracking branch 'origin/fix/album-track-request-badge' into integration/small-fixes | ||
|
|
73dc80bdc9 |
fix(explore): stop hiding the request badge until the row is hovered
The badge on a row you do not own was transparent until the row was hovered or focused. That rule was inherited from the green ticks it replaced, and it does not survive the reason those went: a tick marked the *common* case, while this marks the rows that are not here. A mark on the exception is the information on this page, and one that appears only under the pointer cannot be seen, counted, or reached by anyone driving the app with a finger. The repaint half of #33 is fixed in #82; this is only the visibility, rebased to leave that alone. Refs #33 |
||
|
|
760021ea5a |
fix(downloads): stop searching a list there is nothing to search with
Every pass attempted every request, each came back "no download clients are enabled", and RecordAttempt wrote that down as an attempt and put a retry on the clock -- so a wanted list built deliberately without a client accrued failures and announced "next check in 6 hours" about a check that cannot happen. Wanting something with no way to fetch it is supported. Being told it is being looked for is a lie, and the row says what is true instead. Everything above the attempt still runs: an artist subscription still expands, and a request satisfied by some other route -- ripped, bought, copied in -- is still retired. Neither needs a provider. TestReconcileRespectsBatchSize now installs a client that finds nothing, because a batch size is about how many requests one pass searches for and that only means something when there is something to search with. Refs #37 |
||
|
|
63ec068add | Merge branch 'main' into fix/small-issue-batch | ||
|
|
12e75ee24c |
feat(ui): badge an album drag with how many tracks it carries
Dragging an album to the queue put its cover under the cursor and said nothing about how much that was -- an album is 1 track or 30 and the thumbnail is the same picture either way, so the one number the drop is about was the one thing the drag did not show. Every other drag in the app already says it; this was the exception, because it had a picture to show instead. A count of 1 draws no badge: "1" over a single cover is noise, and the absence reads clearly beside a badge that only appears above one. The badge sits inside the cover's box rather than overhanging it, because setDragImage snapshots the element and anything outside it risks being clipped -- while padding the box instead would move the cover away from the cursor. Refs #19 |
||
|
|
792e87298b |
fix(ui): stop the album grid eating the year it was sorted by
The year sat inside the same ellipsis box as the title, so it was the first thing truncation took: a card wide enough for a long album name never showed its year, and browsing the grid *by year* showed years only for the albums with short names. The sort said one thing and the cards showed another. Title and year are now a flex row where only the title gives way. A row rather than a second line, because the card's height is what the virtualizer measures rows by. Refs #29 |
||
|
|
266e7032dd |
fix(explore): stop labelling the album tracklist "TRACKLIST"
A list of numbered titles with durations, under the album's cover, was the one thing on the page carrying a word above it saying what it is. What goes is the ink and not the element: the section is a landmark and the page's heading structure runs through it, so the h3 stays and is clipped the way sr-only clips -- never display:none, which would take it out of the accessibility tree along with the layout. Refs #9 |
||
|
|
d6b48fb3ac |
fix(player): stop the seek bar resizing as its clocks count
Two different things moved it and they need different answers. Digits in a proportional font are different widths, so 1:11 is narrower than 4:08 and the bar breathed once a second -- tabular figures fix that. The character *count* changes too, at the hundredth minute and whenever the right-hand clock is toggled to remaining and grows a minus sign, which a figure width cannot fix -- so each clock reserves the widest string this track can put in it. The budget is per track rather than a constant: reserving six characters on every track would push the slider in by a character at each end to buy nothing. Measured in the component tier: 4.5px of drift across three positions before, none after. Refs #13 |
||
|
|
185eb1b125 |
feat(smartplaylist): let a rule set match any rule, not only all of them
The conditions were joined with " AND " and nothing else, so a smart playlist could only ever narrow: "jazz released after 1960" was expressible and "jazz or blues" was not, which is most of what anyone reaches for a second rule to say. `RuleSet.Match` is "all" or "any", and an empty match is "all" — which is what every playlist saved before the field existed carries, so an upgrade cannot silently widen one. ParseRuleSet rejects anything else rather than falling through to AND, since a playlist quietly returning the wrong tracks is worse than one that refuses to be saved. Under OR each condition is parenthesised and under AND it is not: AND is the tighter operator, so an OR-join has to protect a condition carrying a top-level AND of its own — `days_since_played less_than` is two predicates belonging to one rule. The editor shows the choice as a sentence with the control in the middle, and hides it while there is one rule: with nothing to combine, all and any are the same query. Closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1062b7c0bc |
fix(explore): tell Lit that a track request changed something
The album page's tracklist badges read `libraryStatusFor(false, track.mbid)` at render time, which is a dependency on `downloadStore` that Lit cannot see. The page did subscribe to that store, but its callback only assigned `canDownload` and `isRequested` — neither of which a *track* request changes — so no reactive field moved and the component never re-rendered. The request was filed, the plus stayed a plus, and clicking again cancelled it. The other three hosts rendering these badges have always asked for the repaint in the same place, which is what made this one look correct on inspection. Closes #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3d375adab1 |
feat(downloads): bound auto-pick by bitrate, and take a good copy
Three faults, one subsystem, and the middle one is why a request that looked obviously satisfiable came back refused. **The guardrails were in megabytes, which cannot mean anything.** 300 MB is a generous FLAC single and a suspiciously small boxset, and whoever fills the field in has no idea which release the pipeline will apply it to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided by how long the music is, so one number holds across a nine-minute EP and a three-hour opera. The runtime comes from `Download.Expected`, which every anchored request already carries, so this costs no lookup; the rate is audio bytes over that, falling back to the mean stated per-file bitrate when the runtime is unknown. Artwork is excluded from the numerator, or a folder with 30 MB of scans reads as a better rip. An unknown runtime *passes* the window rather than failing it: the window is a statement about quality, and refusing everything the moment MusicBrainz is missing a track length would be a silent embargo. `MaxFileSizeMB` survives as a separate ceiling, still in megabytes on purpose -- it is a question about disk space, and it has to apply to a candidate whose bitrate cannot be worked out at all. **Auto-pick required daylight over the runner-up**, 0.08 on the combined score, and so fired hardest in the case it was never written for: a popular album turns up five *correct* copies, all matching the tracklist at 95%+ and differing only in format and seeders, their scores land within a point of each other, and it refused forever on the grounds that the choice was the user's. It was not. There was no question about what to fetch, only about which copy -- and abundance is the condition under which that matters least. A candidate no longer has to beat the field, only clear the bars on its own terms; where several do, ranking puts the one closest to the preferred bitrate first. That tie-break needed the preference to carry weight or it would have been decorative in a new unit: `BitrateFit` was 0.05 against format's 0.42, so asking for 320 and being handed a FLAC every time was the designed behaviour. When a preference is set the weights shift to fit 0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics that exist as stand-ins for the preference the user has now given. Health and priority are untouched. And the fit spans 0.5 to 1.0 rather than 0 to 1, so a preference can promote the copy that matches it and can never push the others under `minQuality` -- turning "I like 320" into "never take anything else" silently is what `MinKbps`/`MaxKbps` are for, out loud. **And a refusal quoted numbers that passed.** The request list built its message from `ranked[0]` -- the best candidate *before* the guardrails and before the lead check -- so a request killed by the size window, or by having too many good copies, reported "best of 12 found is not a confident enough match (match 96%, quality 88%)". `AutoPickVeto` names the gate that actually refused, and `AutoPickable` is that returning empty. Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not migrated. A number meaning "300 MB" cannot be reinterpreted as a rate without knowing the album it was aimed at, so carrying it over would be inventing an intent nobody expressed. Those two fall back to no window, which is the permissive default and what a fresh install gets; `MaxFileSizeMB` carries over unchanged, because a ceiling on bytes still means exactly what it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
e3d492e130 |
fix(downloads): call a request a request, and mark it with a bookmark
The feature was renamed to requests and the copy was not. The badge on every Explore card and track row still offered "Want track X", the album page's button read "Want this" / "Wanted", the artist page's release menu said "Want This", and the Downloads empty state told the user to look for a control by a name nothing rendered. The `queued` badge is a bookmark rather than an hourglass. An hourglass says "wait, this is under way", which overstates what a request is: nothing may be downloading, nothing may ever be found, and the list is somewhere a user can leave one indefinitely. A bookmark says the honest thing -- it is on your list -- and reads as the opposite of the plus that put it there, which is what a toggle's two states have to do. The backend's `'wanted'` request state is deliberately untouched: it is a stored enum, not copy. Also removes a dead duplicate branch in the badge's `render()`. The first `if (this.actionable)` returned before the ring was built, so a partly-held album that could still be requested drew a plus instead of its progress arc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
e6f30b6e43 |
fix(a11y): draw an unfavourited track as an outline, not a dimmer fill
`favCtrl.iconName` returned the solid glyph in both states, so "not a favourite" was a filled heart in a duller colour and the only thing separating the two states was hue. That fails outright for anyone who cannot tell the two colours apart (WCAG 1.4.1), and reads as "everything is a favourite" to everyone else. `iconFor(favorited)` returns the outline or the fill, and the nine `<wa-icon>` call sites split into the two cases they always were. The three that show a *state* -- the mini player, the phone's now-playing view, and the sidebar's marker for the favourites playlist itself -- pass it. The rest are context-menu items, which are actions rather than states and take the outline `iconName` still returns. `track-list` and `album-dropdown` already had this right, from inline SVG paths of their own; this is the same rule for the call sites that go through the icon library. `regular/star` is vendored to go with `regular/heart`, which was already there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
351798fd66 |
fix(ui): spend a row's leftover space on the gaps, not the margins
The three card grids -- albums, artists, genres -- laid out with `justify: 'center'` and a fixed 8px gap and padding, which gives the row a fixed width and pushes everything left over to the two margins. Measured on a 1440px window: cards 16px apart inside 78px of nothing down each side. The outside was five times the inside. `utils/grid-spacing.ts` computes one number instead, from what the row could not spend on another card: the same value between two cards, between two rows, and down each edge. That window now reads 30px outside against 34px between, and it holds at any width. The virtualizer has a word for this -- `justify: 'space-evenly'` with `gap: 'auto'` -- and it cannot be used. It fits `floor(width / cardWidth)` columns without reserving the gap it is about to need, so a width one card short of exact leaves seven cards a pixel apart. On the window above it would fit 7 columns with 1px between them. Deciding the column count here is what puts a floor under the spacing. Two consequences. The layout is rebuilt when the container width changes the spacing rather than only when the cover size changes, so each grid observes its own scroller -- keyed on the spacing, or every pixel of a drag rebuilds a layout that comes out the same. And `cover-grid`'s ScrollManager took `GRID_GAP`/`GRID_PADDING` as constants, which stopped describing anything the moment the spacing became elastic: it asks the host for the geometry now, since a scroll position rebuilt from a stale 8px lands in the wrong row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
40984f6086 |
fix(explore): let a slow archive node finish, and read the 404 back
Explore's album art was almost entirely missing: 5 of 24 cards on the shelves had a cover, and those five were the ones already on disk. The Cover Art Archive answers `front-250` with a 307 to an Internet Archive storage node, and those nodes are slow. Measured against the twelve albums on Explore's own shelves, a successful fetch took 14-16 s and a failing one 13-17 s, against a client timeout of 10. So every live fetch died, and a timeout writes nothing and says nothing -- which is why this reads as "Explore has no album art" rather than as a slow upstream. The timeout is 30 s, chosen to clear the measured range: the fetch is off the critical path, so waiting costs nothing and giving up early costs the whole page. Two things beside it, both found on the way. `writeCache(mbid, nil)` has recorded "the archive has no art for this" as an empty file since it was written, and nothing has ever read it back: `readCache` returns "" for an empty file, which is indistinguishable from a miss. So every art-less release group was re-fetched from CAA on every render that asked about it. A third of the shelves are art-less, so that was a third of the page spending a live request to be told again what the last one said. `knownMissing` reads it, on both the release-group and the release path. And the frontend marked a failed fetch as permanently answered for the session, so a timed-out cover never retried within it. It drops the marker instead; a genuine 404 is now answered from disk, so re-asking one costs nothing. Measured after: 23 of 24. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L |
||
|
|
b505959934 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
de2b324e20 |
feat(explore): refuse 0.6 GB on someone's mobile data
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it with no awareness of the connection: on a desktop that is a minute of bandwidth, on a phone it can be a month's allowance. It is now skipped on a cellular connection unless `AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search Index section, where the text explaining what the catalog is already lives. The file layout is dictated by the cgo rule rather than by taste. `explore` is imported by `cmd/indexbuild`, which builds with CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy and the JSON parsing -- tested on every platform -- and the single platform call is a closure injected from `app.go`, which already names `application` legitimately. Three rules in it are load-bearing. An unknown answer is not a metered one: only mobile answers at all, and treating silence as metered would have disabled the download for every desktop user in the world. Cellular is the only signal available, because the runtime reports `wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi cannot be detected and is not refused, which is documented rather than implied. And the gate runs before the first status write, so declining is a no-op instead of a job in the indicator and an error tier to dismiss. Two corrections to the plan while implementing it: the portable API is `application.Mobile.NetworkJSON()`, not `application.Android`'s, which exists only under the `android` build tag; and the permission is read at the moment a download would start, so enabling it takes effect on the next attempt rather than the next launch. |
||
|
|
2c78b58207 |
feat(ui): the track list a phone can read
B2 phase 4, and the last of it. Measured on the device: at 424 CSS px the four configured columns fit the row *exactly* -- `--grid-cols` came out `24px 102px 101px 101px 80px` -- and not one of them fit its content, with "Duration" too narrow for its own header. The columns were never too wide; there were too many of them. So a phone draws `titleArtist` (the title with the artist under it, across the row's whole width) plus the duration, and drops the column headers and the resize handles, which are a click-to-sort and a drag with no touch equivalent. It is a **column set, not a second row template**: the row, its delegated events, the selection semantics, the playing marker and the virtualizer never learn anything changed, because from their side only the number of columns did. Three rules come with it. The row height is in two places (`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the virtualizer positions rows from that number and a taller row overlaps its neighbour. What is drawn and what can be sorted are different questions, so the sort list is built from `configuredColumns` -- a phone has no headers either, and building it from the drawn columns would leave it able to sort by title and duration alone. And a phone's column widths are neither loaded nor saved. That third rule is the bug the device found with the arrangement already passing five component tests and five e2e specs at the phone's own viewport. `loadColumnWidths` is keyed by column *id* and fills a gap with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever have saved a width for -- came out at 148px beside a duration column of 236. The mirror image was worse and unreachable from a phone at all: saving would have written those widths back under the same ids, replacing the width the user dragged on a desktop. The specs asserted shape, and the fault depended on what `localStorage` held for a different column set; the unit test now carries that map as a fixture. Verified: 809 component tests, 112 e2e specs, and on the phone at 424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow. One full e2e run of three saw an unrelated autotag keypress spec flake and pass on retry. |
||
|
|
0eeef6048e |
feat(frontend): credit the artists on the full-screen now playing too
The phone shell's now-playing view landed on main while the credit rendering was being written, so it arrived with the one call site that still showed a multi-artist credit as a single link with the other artists as punctuation inside it. It is the same fix as the other ten: render from the parts, fall back to the single link when there are fewer than two. The subscription is what makes it show up at all — credits arrive after the track does, so the name already on screen has to be re-rendered when they land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
4fc0cdeab7 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
dcabec8b1d |
feat(frontend): render a multi-artist credit as one link per artist
Every artist name in the app went through `artistLink(name, mbid)`, so a track credited to several artists rendered one link and the rest as punctuation — "2Pac feat. Snoop Dogg" linked 2Pac and left Snoop Dogg as text inside it. `creditLink(parts, fallbackName, fallbackMbid)` renders the credit from its parts: one link per credited artist, join phrases as plain text between them. The link boundaries are known by construction, which is the point — locating a name inside the stored credit string would reintroduce the mismatch the catalog exists to avoid, since that string may come from the file's tags while the parts come from MusicBrainz and the two disagree for ~1 in 3 multi-artist credits. Fewer than two parts falls through to the previous behaviour exactly, so a single-artist credit, a file with no recording MBID and a catalog that has not answered yet all render as they did before. Nothing tries to split the fallback string: "Simon & Garfunkel" is one artist, which is why primaryArtist() does not split on "&" either. The lookup is keyed on the recording MBID, which both sides already carry — a catalog row has one and so does a local file — so one binding serves Explore and the library's own lists, and no local table is needed for this. credit-store.ts, and three things in it are load-bearing: - A miss is cached as an empty array. The backend returns nothing for a single-artist credit, which is ~87% of tracks, and caching only the hits would re-request the rest on every render forever. - request() is per-row and coalesces into one call per frame. A virtualized list cannot hand over "the whole list": 50,000 rows would be 100 queries for the ~30 on screen. - It is an LRU with a counted retainedChars probe, because a cache that grows with use is a leak with a schedule. The virtualized lists push requestUpdate() into the virtualizer rather than only the host, since its rows come from its own properties — a host update alone would leave them exactly as they were. now-playing marks its geometry dirty instead, because the marquee measures the text it is about to scroll. track-list keeps the single link while a search term is active: the highlight spans are computed against the flat credit string, and mapping them onto decomposed parts is a different problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
1b05dde382 |
feat(ui): the full-screen now playing a phone needs
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of the phone's bottom bar -- 4px of height is not a thumb target, and a phone's volume belongs to its hardware keys -- and promised them a full-screen view. This is it, reached from a button over the mini player's cover art. **It composes the transport rather than reimplementing it.** The same `seek-bar`, `player-controls` and `volume-control` the desktop bar uses; a phone layout that copies them is a second transport to fix every bug in, and the seek bar in particular carries interpolation rules that took a plan of their own to get right. The seek bar thickens its own track below the breakpoint, in its own stylesheet, because the track size lives on a wa-slider inside its shadow root where a custom property from the host cannot reach. **It is a detail view, not a primary one.** It is somewhere you go and come back from, so index.ts pushes the current view and Back pops it -- which is also why it is not a fifth tab: a tab you cannot leave by pressing it again is not a tab. Two things came from reading a screenshot rather than from a failing test, and both were invisible to assertions that were individually correct. **The mini player was still under the full-screen view**, repeating it in 4em of an 844px phone. index.css hides the bottom bar while `#main-content[data-active-view="now-playing"]`, through `:has()` rather than a class toggled from index.ts, because the active view is already published as an attribute. That takes the queue button with it, so the view carries its own. **And phase 1's shell rules had never applied.** A media query adds no specificity, and the phone block sat above the plain rules it meant to override, so at 390px the header kept its 2em gutters (32px), its 16px gap and its 24px title, and the bottom bar kept a fixed 320px first column. Nothing failed: the shell fits because of `min-width: 0` and each component's own media query, which live in their own stylesheets and have no later rule to lose to -- so what was dead was exactly the cosmetic half no assertion looks at. The phone rules are one section at the end of the file now, and it says why it is last. Measured after: 12px, 8px, 17.6px, `154px 187px 33px`. |
||
|
|
57fbbdf0d2 |
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for. |
||
|
|
e14a34fccf |
fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app could not work at all on a phone. **It had no permission to read anything.** The generated manifest asked for INTERNET, VIBRATE, biometrics, location and a camera, and nothing whatever about storage -- so at targetSdk 35 the app could see its own private directory and no music. It now declares READ_MEDIA_AUDIO, the two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE. That last one is deliberate and is the load-bearing choice. This app is a library manager: audio_files.file_path is the primary key of ownership, the scanner walks a directory the user chose, and tagwriter rewrites files in place. MediaStore offers no stable directory to walk and no in-place write, so scoped storage is not "more work" here, it is a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted, which is acceptable only because this ships as an APK through the package registry -- if it ever targets Play, that line is what has to go, and plan 016 says what replaces it. It is granted on a Settings screen rather than in a dialog, so it cannot be requested with requestPermissions(). MainActivity opens that screen on every cold start until access exists -- there is no degraded mode worth offering -- and re-checks in onResume, because the way back from another task is a resume, emitting android:storageAccess so the frontend can react. **The first-run flow could not complete.** All three call sites asked for a folder through the Wails dialog, which returns an error on Android: SAF yields tree URIs and this app is keyed on paths. So the app browses the filesystem itself, which it can now do. ListDirectories lists directories only (the thing being chosen is a library root), skips what it cannot stat rather than failing the listing (Android's storage root holds directories no app may enter), follows symlinks (os.DirEntry reports the link, so a symlinked music folder would silently vanish), and hides dotted entries. utils/pick-directory.ts is the one place that chooses between the two, so the three call sites changed by one line each. **Which platform is asked of the backend**, not of System.IsAndroid(): the dialog is backend code, so the backend is what knows whether it can open one; it answers for iOS at the same time; and it keeps the fallback testable through the ordinary transport fake rather than a module mock of the Wails runtime, whose platform helpers read build constants. **And MPRIS was compiled into the Android build**, because android implies the linux build tag, so it went looking for a session bus that does not exist. mpris_linux.go is `linux && !android` now and the stub covers Android, which means no lock-screen transport there yet -- a missing feature rather than a broken one, and the remaining blocker. The foreground service is typed mediaPlayback rather than the scaffold's dataSync, with the matching permission, so playback can survive the screen locking once there is a MediaSession to drive it. The type in the manifest and the one passed to startForeground must agree or startForeground throws. |
||
|
|
dd17a4d8eb |
Merge origin/main into wails-v3
21 conflicts, all from the same cause: three features were developed on both lines and this branch's copies are the ones adapted to v3's bindings and to the file-shaped schema. Resolutions: - `frontend/wailsjs/` stays deleted — v2's generated bindings, replaced by `frontend/bindings/`. - remove-from-library, `library-status.ts`, the requested-badge spec and its component test: took this branch's copies, which differ from main's only in calling `pruneEmptyEntities`/`CountAudioFiles`, importing `@go/download/models.js`, and staging a real UUID for the catalog's `CHECK(length(mbid) = 16)`. - `GetFilePathsByRecordingMBIDsByLibrary` dropped: it joined `recordings`, which no longer exists, and `library_id = 0` answers both scoped and unscoped now. `GetAudioFilesByPaths` was already here. - The album page, the artist page and the library badge kept this branch's versions, which supersede main's: ownership asked once from the files, the partial-completeness ring, and the request action. - Docs: no migration chain (013) over main's two-file column rule and its pre-1.0 squashing note, both of which 013 retired. Kept main's `CreateSmartPlaylist` read-pool example, which is a real second instance of that bug. Verified on the merge result, not on either parent: lint clean in all three build configurations, `make test` green in all three, 776 Vitest tests, `tsc --noEmit`, bindings-check and skill-check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
e7748f1fd5 |
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
162c68769f |
feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place — a real TypeScript module tree nested by Go import path, generated by wails3's static analyser rather than by building the app and running it. The @go alias absorbs the constant prefix, so a call site imports '@go/library/library.js' and the codemod over all 93 sites was a specifier rewrite plus splitting @go/models' namespaces into one import per package. The 12 SetContext bindings and the fake `context` model are gone, as Phase 2's ServiceStartup port promised: 272 methods across 12 services, none of them plumbing. @runtime/runtime is now a local shim (src/wails/runtime.ts) over @wailsio/runtime, so the 22 EventsOn imports are untouched. It unwraps v3's WailsEvent into v2's callback shape, which is exact here: nothing in backend/events passes more than one data argument, and v3 only packs arguments into a slice when there is more than one. v3 tells the truth about two things v2 lied about, and that is most of the diff. A Go nil slice really does arrive as JSON null, and a Go named string type really is an enum; v2 typed them as T[] and string. utils/binding.ts states the app's actual contract — an absent list is an empty list — once, at the boundary where it is true, and also drops the CancellablePromise the app never cancels. Four test fixtures widen an enum field back to its value union. Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts still fakes window.go, which v3 does not have, so `make ui-test` is broken and harness.test.ts fails to compile on EventsEmit. That test also asserts v2 ordering that no longer holds — v3's Events.Emit calls the backend and does not notify in-page listeners at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
edb13a6f39 |
perf(explore): ask the disk once, prefetch once, and menu the releases
Three things on the Explore surfaces, all about not asking twice. A portrait already on disk costs no network call. explore-view seeded only from the library store — owned artists, which on a catalog search is nearly none of the results — and sent everything else to GetArtistImageURL, the resolving entry point, one await at a time. GetArtistImagesCachedPaths asks the disk about every unresolved artist in one call, and only what it does not answer reaches the resolver, in parallel. The artist page's two sections both wanted PrefetchReleases and each called it, so the most expensive call the app makes was issued twice for an overlapping set on a 1 req/s limiter. They are collected and sent once on a microtask, and prefetchRequested stops the cold-artist refetch re-asking for what it already asked for. The release cards — most of the artist page — had no context menu at all. They have one now on both release shapes, normalised to a ReleaseMenuTarget when the menu opens so the union does not reach the action handlers. It is a discriminated union rather than one nullable field per kind because the panel is shared with the track menu: that is what keeps aria-label moving with the target, which is the fault cover-grid shipped. Which items appear is three different questions — playback is gated on a local album id, not on "owned", and the request needs a catalog MBID, so it is absent for a library-only release. Note on the docs: the CLAUDE.md and NOTES.md prose here was reconstructed after a mishandled `git stash --keep-index` destroyed the uncommitted originals. One NOTES.md section is marked as incomplete where its text could not be recovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
dc890d1fcc |
feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm |
||
|
|
dcc40b1781 |
feat(albums): get an album's track total from the files, not the catalog
The album page asked MusicBrainz how many tracks an album has, because the only total it had was the length of the tracklist it was already showing — a tautology for a library copy. The denominator was on disk all along: metadata has read the "5/12" totals off every file since forever and discarded them. They persist to release_group_recordings.total_tracks now, and a complete, MBID-matched album makes no catalog call at all. Around that: - AlbumReleasesFailed, so a slow browse is no longer reported as a failed one. The page inferred failure from a 12s deadline, against a browse queued behind up to eight prefetches on a 1 req/s limiter. - Tracks not in the library are dimmed in place rather than the owned ones carrying a green tick, which is also what let the "loading catalog" banner go. - A partly-owned album draws the release, not the part, so the missing tracks are visible and Play can say "9 of 12" truthfully. - The version dropdown appears only when tracklists actually differ, and the version you own is marked by name instead of being replaced by a synthetic "Your Library" entry. - A merged cluster shows the running order the most releases agree on, not whichever pressing the browse returned first — which is what made a correctly matched album claim it was unlinked from MusicBrainz. Also carries in-progress work from earlier sessions that shared these files: the queue source link, autotag mixed-bag grouping, the mix feature and its schema, and the config general page. Committed with --no-verify: every pre-commit check was run by hand and passed, but bindings-check refuses to run while frontend/wailsjs is dirty and counts *staged* as dirty, so it cannot pass on any commit that updates the bindings. Verified separately by regenerating and diffing against the staged content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP |
||
|
|
e61b7456df |
feat(explore): make the library badge request what it is on
007 turned this badge from a `<button>` whose handler was a `stopPropagation()` and a TODO into `role="img"`, on the rule that a control which cannot act is worse than none — and wrote down what would change the answer: a `<button>` again *with* a handler, never a handler bolted onto something already shaped like one. This is that. A call site opts in by passing `request-mbid`, so where a badge is redundant it stays a badge: `explore-album-details`'s header has "Want this" in words directly below it, and its template says so by not opting in. An `in-library` badge is never a button either, because there is nothing left to ask for — that is what keeps the tab stops 007 gave back from being spent on nothing. The copy is the action, not the state, and it is deliberately about the request list rather than the library: "Want album X" / "Cancel the request for album X". Clicking still adds nothing to the library, which is what made the original "Add … to library" a promise the control could not keep. Tracks are requestable too. `EntityRecording` is not a placeholder in the request model — `Reconciler.tracklistFor` has a deliberate branch for it, because one expected title is what lets filename matching score a single-track download at all. Artists are not: there is no artist badge anywhere, and a discography subscription belongs on the Follow button that can say what it commits to. The click is swallowed again, for the opposite reason to before: with an action of its own, a click on the badge no longer means what the card means. Enter and Space are stopped for the same reason — every card holding one is a role=button or role=option with its own handler. |