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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 417.1c0 25.6-28.5 40.8-49.8 26.6L192 412.8 49.8 507.7C28.5 521.9 0 506.6 0 481.1L0 64zM64 48c-8.8 0-16 7.2-16 16l0 387.2 117.4-78.2c16.1-10.7 37.1-10.7 53.2 0L336 451.2 336 64c0-8.8-7.2-16-16-16L64 48z"/></svg>
|
||||
|
After Width: | Height: | Size: 566 B |
@@ -26,6 +26,7 @@ import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
import { libraryStatusFor } from '@utils/library-status';
|
||||
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
@@ -623,6 +624,36 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* The request control is only offered where there is
|
||||
* something to request, and only when the row is being
|
||||
* attended to — a column of plus signs down a mostly-owned
|
||||
* album is the clutter the green ticks were.
|
||||
*
|
||||
* Hidden with opacity, never display:none or visibility,
|
||||
* so it keeps its place in the layout (rows do not reflow
|
||||
* as the pointer moves) and stays in the tab order and the
|
||||
* accessibility tree. focus-within is what makes it
|
||||
* reachable without a mouse: tabbing to the button reveals
|
||||
* it, and the row's own focus reveals it before you get
|
||||
* there. */
|
||||
.track-row .track-request {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.track-row:hover .track-request,
|
||||
.track-row:focus-within .track-request,
|
||||
.track-row .track-request:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.track-row .track-request {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1902,7 +1933,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
* - cachedAlbums has MBID match → owned
|
||||
* - any selected version has → owned (covers local-only albums
|
||||
* a track marked inLibrary where releaseGroup may be null)
|
||||
* - else → not owned
|
||||
* - else → whatever the request list says
|
||||
*
|
||||
* Four different claims of decreasing confidence, OR'd together and
|
||||
* reported as one tick — the last of which fires when a *single*
|
||||
@@ -1911,8 +1942,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
* actions key off; this stays as it was, because the indicator's
|
||||
* job is "is any of this yours" and that is what it answers.
|
||||
*
|
||||
* No queued state for now — that's reserved for future
|
||||
* download-client integration.
|
||||
* When none of them hold the answer is not automatically "no":
|
||||
* the album may be on the request list, which the button directly
|
||||
* below this badge has reported as "Wanted" all along.
|
||||
*/
|
||||
/**
|
||||
* What the badge beside the album title shows.
|
||||
@@ -1934,7 +1966,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
return 'in-library';
|
||||
}
|
||||
|
||||
private albumLibraryStatus(): 'in-library' | 'not-in-library' {
|
||||
private albumLibraryStatus(): LibraryStatus {
|
||||
if (this.localAlbumId > 0) return 'in-library';
|
||||
|
||||
if (this.releaseGroup?.inLibrary) return 'in-library';
|
||||
@@ -1956,7 +1988,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
}
|
||||
}
|
||||
|
||||
return 'not-in-library';
|
||||
// None of the five ownership claims held, so the badge falls
|
||||
// through to the one thing this page already knew and never
|
||||
// said: whether the album is on the request list. The button
|
||||
// below it has read "Wanted" all along.
|
||||
return libraryStatusFor(false, this.releaseGroupMBID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2465,9 +2501,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
||||
@click=${() => void this.toggleRequested(request?.id)}
|
||||
>
|
||||
<!-- The requested state used to ask for bookmark-check,
|
||||
which is a Font Awesome *Pro* name: never bundled,
|
||||
so this button has rendered the missing-icon
|
||||
fallback in that state ever since. Outline and solid
|
||||
of the same Free glyph carry the toggle instead. -->
|
||||
<wa-icon
|
||||
slot="start"
|
||||
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
|
||||
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
|
||||
></wa-icon>
|
||||
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||
</wa-button>
|
||||
@@ -2906,6 +2947,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
track.length,
|
||||
)}</span
|
||||
>
|
||||
${track.inLibrary
|
||||
? nothing
|
||||
: html`
|
||||
<library-status-indicator
|
||||
class="track-request"
|
||||
status=${libraryStatusFor(
|
||||
false,
|
||||
track.mbid,
|
||||
)}
|
||||
entity-type="track"
|
||||
label=${track.title}
|
||||
request-mbid=${track.mbid}
|
||||
request-artist=${this.artistName}
|
||||
></library-status-indicator>
|
||||
`}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
import { libraryStatusFor } from '@utils/library-status';
|
||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||
import { queueStore } from '../../store/queue-store';
|
||||
@@ -2471,9 +2472,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
${formatListenCount(t.totalListenCount)} plays
|
||||
</span>
|
||||
<library-status-indicator
|
||||
status=${t.inLibrary || t.localId ? 'in-library' : 'not-in-library'}
|
||||
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
|
||||
entity-type="track"
|
||||
label=${t.trackName}
|
||||
request-mbid=${t.recordingMbid}
|
||||
request-artist=${t.artistName ?? ''}
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
`,
|
||||
@@ -2583,9 +2586,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
||||
</div>
|
||||
<library-status-indicator
|
||||
status=${rg.inLibrary || rg.localId ? 'in-library' : 'not-in-library'}
|
||||
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.releaseGroupMbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
size="18"
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
@@ -2678,9 +2683,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
|
||||
const year = extractYear(rg.firstReleaseDate);
|
||||
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
|
||||
const status: 'in-library' | 'not-in-library' = inLibrary
|
||||
? 'in-library'
|
||||
: 'not-in-library';
|
||||
const status = libraryStatusFor(inLibrary, rg.mbid);
|
||||
|
||||
return html`
|
||||
<div
|
||||
@@ -2717,6 +2720,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
status=${status}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${this.artist?.name ?? ''}
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { avatarBackground } from '@utils/avatar-color';
|
||||
import { libraryStatusFor } from '@utils/library-status';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
@@ -786,6 +788,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
|
||||
if (this.shelves?.state !== 'ready') void this.loadShelves();
|
||||
});
|
||||
|
||||
// The badges on every result card say whether something is
|
||||
// already requested, which a background reconcile pass changes
|
||||
// without this page doing anything. Registered `whileActive`
|
||||
// rather than on connect: this view is cached and never
|
||||
// unmounts, so a connect-time subscription would run for the
|
||||
// life of the session from pages it is not on.
|
||||
//
|
||||
// `init()` is four fetches, and it happens on arrival for the
|
||||
// same reason `loadShelves()` does — a user who never opens
|
||||
// Explore should not pay for it.
|
||||
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
|
||||
void downloadStore.init().then(() => this.requestUpdate());
|
||||
}
|
||||
|
||||
/** A debounced search that lands after the user has left the page is
|
||||
@@ -1816,6 +1831,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
|
||||
<input
|
||||
type="text"
|
||||
aria-label=${this.searchMode === 'lyrics'
|
||||
? 'Search the catalog by a lyric'
|
||||
: 'Search the catalog'}
|
||||
placeholder=${placeholder}
|
||||
.value=${this.searchQuery}
|
||||
@input=${this.handleInput}
|
||||
@@ -2146,9 +2164,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
</div>
|
||||
<library-status-indicator
|
||||
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
|
||||
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
|
||||
entity-type="album"
|
||||
label=${rg.title}
|
||||
request-mbid=${rg.mbid}
|
||||
request-artist=${rg.artistCredit ?? ''}
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2207,9 +2227,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
: nothing}
|
||||
</div>
|
||||
<library-status-indicator
|
||||
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
|
||||
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
|
||||
entity-type="track"
|
||||
label=${r.title}
|
||||
request-mbid=${r.mbid}
|
||||
request-artist=${r.artistCredit ?? ''}
|
||||
></library-status-indicator>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { toggleRequest } from '@utils/library-status';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
|
||||
/**
|
||||
* Library status for an entity (artist, album, or track).
|
||||
@@ -25,18 +28,23 @@ export type LibraryStatus =
|
||||
* Tri-state library status indicator: a small circular badge embedded
|
||||
* in track rows, album cards, and artist cards.
|
||||
*
|
||||
* **It is a badge, not a control.** It was a `<button>` whose click
|
||||
* handler was a `stopPropagation()` and a comment saying to wire up
|
||||
* the download client later — so an Explore results page offered 20
|
||||
* keyboard stops (of 66) that promised an action and performed none,
|
||||
* and every one of them announced itself as a button. A control that
|
||||
* cannot act is worse than no control: it costs the keyboard user the
|
||||
* tab stop *and* the expectation.
|
||||
* **It is a button only where it can act, and a badge everywhere
|
||||
* else.** It used to be a `<button>` whose click handler was a
|
||||
* `stopPropagation()` and a comment saying to wire up the download
|
||||
* client later, so an Explore results page offered 20 keyboard stops
|
||||
* (of 66) that promised an action and performed none. 007 made it
|
||||
* `role="img"` for that reason and wrote down what would change the
|
||||
* answer: a `<button>` again *with* a handler, never a handler bolted
|
||||
* onto something already shaped like one.
|
||||
*
|
||||
* So it is `role="img"` with a label, until there is something to
|
||||
* click. When the download-client integration lands, the right change
|
||||
* is to make it a `<button>` again *with a handler* — not to add the
|
||||
* handler to something already shaped like a button.
|
||||
* A call site opts in by passing `request-mbid`. Where it does, this
|
||||
* is a `<button>` that toggles a durable **request** — and the copy
|
||||
* says so, because clicking still adds nothing to the library. Where
|
||||
* it does not (`explore-album-details`'s header, which has "Want this"
|
||||
* in words directly below it) it stays exactly what it was.
|
||||
*
|
||||
* An `in-library` badge is never a button under either: there is
|
||||
* nothing left to ask for.
|
||||
*
|
||||
* Colours and glyphs:
|
||||
* - in-library → green circle, check mark
|
||||
@@ -89,6 +97,38 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
@property({ type: Number })
|
||||
expected = 0;
|
||||
|
||||
/**
|
||||
* MBID to request when this is clicked. Supplying it is what makes
|
||||
* this a control; omitting it leaves a badge. Only `album` and
|
||||
* `track` are requestable — see `utils/library-status.ts`.
|
||||
*/
|
||||
@property({ type: String, attribute: 'request-mbid' })
|
||||
requestMbid = '';
|
||||
|
||||
/** Display-cache artist for the request list. Matching is by MBID. */
|
||||
@property({ type: String, attribute: 'request-artist' })
|
||||
requestArtist = '';
|
||||
|
||||
@state()
|
||||
private busy = false;
|
||||
|
||||
/**
|
||||
* True when this can act: a call site opted in, and there is
|
||||
* something left to ask for.
|
||||
*
|
||||
* `partial` is deliberately actionable — an album you hold nine of
|
||||
* twelve tracks of has three left to request, which is exactly the
|
||||
* case worth asking about. Only `in-library` is complete enough to
|
||||
* have nothing to ask for.
|
||||
*/
|
||||
private get actionable(): boolean {
|
||||
return (
|
||||
this.requestMbid !== '' &&
|
||||
this.status !== 'in-library' &&
|
||||
this.entityType !== 'artist'
|
||||
);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
@@ -148,7 +188,8 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
/* A <button> gets box-sizing: border-box from the UA
|
||||
* stylesheet and a <span> does not, so dropping the button
|
||||
* grew the badge by its 1px border on each side — 36px to
|
||||
* 38px, caught by the stored screenshot. */
|
||||
* 38px, caught by the stored screenshot. Set explicitly so
|
||||
* the two branches of render() are the same size. */
|
||||
box-sizing: border-box;
|
||||
width: var(--indicator-size);
|
||||
height: var(--indicator-size);
|
||||
@@ -168,6 +209,29 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
wa-icon {
|
||||
font-size: calc(var(--indicator-size) * 0.55);
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button.badge {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button.badge:hover:not(:disabled) {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
|
||||
button.badge:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* The card underneath draws its own focus ring, and this sits
|
||||
* inside it — so the badge needs one of its own or a keyboard
|
||||
* user cannot tell which of the two has focus. */
|
||||
button.badge:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Prevent the button from intercepting drag gestures on album
|
||||
@@ -204,6 +268,17 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
: 'track';
|
||||
const name = this.label ? ` "${this.label}"` : '';
|
||||
|
||||
// A control is named after what activating it does; a badge is
|
||||
// named after what it is. Both are still deliberately about the
|
||||
// *request list* rather than the library — clicking this adds a
|
||||
// row to one and nothing to the other, and "Add … to library"
|
||||
// was the old button's promise written into the copy.
|
||||
if (this.actionable) {
|
||||
return this.status === 'queued'
|
||||
? `Cancel the request for ${kind}${name}`
|
||||
: `Want ${kind}${name}`;
|
||||
}
|
||||
|
||||
switch (this.status) {
|
||||
case 'in-library':
|
||||
return `${capitalize(kind)}${name} is in your library`;
|
||||
@@ -214,12 +289,64 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
case 'queued':
|
||||
return `${capitalize(kind)}${name} is queued for download`;
|
||||
default:
|
||||
// Not "Add … to library": nothing here adds anything.
|
||||
// The old copy was the button's promise written out.
|
||||
return `${capitalize(kind)}${name} is not in your library`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the request.
|
||||
*
|
||||
* The click is swallowed, which it was before too — but for the
|
||||
* opposite reason. 007 removed a `stopPropagation()` that guarded
|
||||
* nothing, on the rule that with no action of its own the badge is
|
||||
* part of its card and a click on it should mean what the card
|
||||
* means. Now it has one, so it does not.
|
||||
*/
|
||||
private async onActivate(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (this.busy || !this.actionable) return;
|
||||
|
||||
this.busy = true;
|
||||
|
||||
try {
|
||||
await toggleRequest({
|
||||
mbid: this.requestMbid,
|
||||
entity: this.entityType === 'album' ? 'album' : 'track',
|
||||
title: this.label,
|
||||
artist: this.requestArtist,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('could not update the request list', err);
|
||||
|
||||
// Transient: the badge visibly stayed where it was, so
|
||||
// there is nothing for the user to do about it that they
|
||||
// are not already doing.
|
||||
notificationStore.transient({
|
||||
text: describeError(err, 'That request could not be updated.'),
|
||||
tone: 'error',
|
||||
});
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Enter and Space from reaching the card underneath.
|
||||
*
|
||||
* A `<button>` fires `click` on both by itself, so this only has to
|
||||
* stop the keydown propagating — every card holding one of these is
|
||||
* a `role="button"` or `role="option"` with its own Enter/Space
|
||||
* handler, and without this a keyboard activation would both file
|
||||
* the request and open the page.
|
||||
*/
|
||||
private onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Sync the host CSS variable with the configured size.
|
||||
if (this.size && this.size !== 20) {
|
||||
@@ -228,13 +355,35 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
|
||||
const title = this.tooltip();
|
||||
|
||||
// The ring stands in for the icon wherever the icon would go —
|
||||
// including inside the button, because a partly-held album is
|
||||
// actionable (it has tracks left to request) and must still
|
||||
// show how much of it is here.
|
||||
const glyph = this.status === 'partial'
|
||||
? this.renderRing()
|
||||
: this.iconName()
|
||||
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
|
||||
: nothing;
|
||||
|
||||
if (this.actionable) {
|
||||
return html`
|
||||
<button
|
||||
class="badge"
|
||||
type="button"
|
||||
title=${title}
|
||||
aria-label=${title}
|
||||
?disabled=${this.busy}
|
||||
@click=${this.onActivate}
|
||||
@keydown=${this.onKeydown}
|
||||
>
|
||||
${glyph}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<span class="badge" role="img" title=${title} aria-label=${title}>
|
||||
${this.status === 'partial'
|
||||
? this.renderRing()
|
||||
: this.iconName()
|
||||
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
|
||||
: nothing}
|
||||
${glyph}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import '../library-status-indicator/library-status-indicator.js';
|
||||
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||
import { libraryStatusFor } from '../../utils/library-status';
|
||||
import { downloadStore } from '../../store/download-store';
|
||||
|
||||
/** Format milliseconds as m:ss. */
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
@@ -50,6 +52,28 @@ export class TopResultsRow extends LitElement {
|
||||
// Per-card state: cover images.
|
||||
private images = new Map<string, string>();
|
||||
|
||||
private unsubRequests?: () => void;
|
||||
|
||||
/**
|
||||
* The badges here say whether something is already requested, and
|
||||
* this row will not hear about a change from its host: `explore-view`
|
||||
* re-rendering sets the same `results` array back, so Lit stops at
|
||||
* the property and never updates this element. One subscription for
|
||||
* the row, not one per card.
|
||||
*/
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.unsubRequests = downloadStore.subscribe(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.unsubRequests?.();
|
||||
this.unsubRequests = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
exploreLinkStyles,
|
||||
@@ -255,7 +279,10 @@ export class TopResultsRow extends LitElement {
|
||||
? r.year || ''
|
||||
: formatDuration(r.length) || '';
|
||||
|
||||
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
|
||||
const status: LibraryStatus = libraryStatusFor(
|
||||
Boolean(r.inLibrary),
|
||||
r.mbid,
|
||||
);
|
||||
const entityType: 'artist' | 'album' | 'track' =
|
||||
r.entityType === 'artist'
|
||||
? 'artist'
|
||||
@@ -314,6 +341,8 @@ export class TopResultsRow extends LitElement {
|
||||
status=${status}
|
||||
entity-type=${entityType}
|
||||
label=${r.name}
|
||||
request-mbid=${r.mbid}
|
||||
request-artist=${r.artistCredit ?? ''}
|
||||
size="22"
|
||||
></library-status-indicator>`}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,9 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
|
||||
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { describeError } from '@utils/describe-error';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||
import { RemoveFromLibrary } from '@go/library/Library';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
@@ -1212,8 +1215,29 @@ export class TrackList
|
||||
'shortcut:tracklist-play',
|
||||
this.handleShortcutPlay,
|
||||
);
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'shortcut:tracklist-delete',
|
||||
this.handleShortcutDelete,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete opens the confirmation and does nothing else.
|
||||
*
|
||||
* That is the whole design of the binding: one keystroke from a
|
||||
* focused row, a key that *asks* is defensible and a key that
|
||||
* *acts* is not — so this is the same dialog the menu command
|
||||
* opens, reached by a different route.
|
||||
*/
|
||||
private handleShortcutDelete = (): void => {
|
||||
const filePaths = this.selection.getSelectedKeysOrdered();
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
void this.removeFromLibrary(filePaths);
|
||||
};
|
||||
|
||||
/** Enter plays the selection — the `tracklist.play` binding, which
|
||||
* has existed in the defaults and in Settings since it was written
|
||||
* and has never had anything on the other end of it. */
|
||||
@@ -1639,12 +1663,79 @@ export class TrackList
|
||||
void this.openBatchTrackDetails(filePaths);
|
||||
}
|
||||
break;
|
||||
case 'remove-from-library':
|
||||
// The only destructive command in this menu: it asks
|
||||
// first, and it keeps the selection until the user has
|
||||
// answered — the dialog names a count, and clearing the
|
||||
// selection under it would make that count a claim
|
||||
// about nothing.
|
||||
this.ctxMenu.close();
|
||||
void this.removeFromLibrary(filePaths);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.clear();
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* "Remove from library", behind a confirmation that says what it
|
||||
* does *and* what it does not.
|
||||
*
|
||||
* The second half is the point. This deletes the database rows and
|
||||
* stops the scanner importing those paths again; the audio files
|
||||
* are left exactly where they are. A user who reads "remove" as
|
||||
* "delete" and finds their music gone would have been failed by the
|
||||
* copy, not by the operation — so the copy says so in the impact
|
||||
* line, where the consequence of every other destructive action in
|
||||
* the app is written.
|
||||
*/
|
||||
private async removeFromLibrary(filePaths: string[]) {
|
||||
const count = filePaths.length;
|
||||
const only =
|
||||
count === 1
|
||||
? tracksByFilePath(this.tracks).get(filePaths[0]!)
|
||||
: undefined;
|
||||
|
||||
const ok = await confirmAction({
|
||||
title:
|
||||
count === 1
|
||||
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
|
||||
: `Remove ${count.toLocaleString()} tracks from the library?`,
|
||||
message:
|
||||
count === 1
|
||||
? 'It is removed from YellowJacket and will not be added' +
|
||||
' back by a future scan.'
|
||||
: 'They are removed from YellowJacket and will not be' +
|
||||
' added back by a future scan.',
|
||||
impact:
|
||||
count === 1
|
||||
? 'The file is not deleted — it stays on disk exactly' +
|
||||
' where it is. A full rescan brings it back.'
|
||||
: 'The files are not deleted — they stay on disk exactly' +
|
||||
' where they are. A full rescan brings them back.',
|
||||
confirmLabel:
|
||||
count === 1
|
||||
? 'Remove track'
|
||||
: `Remove ${count.toLocaleString()} tracks`,
|
||||
danger: true,
|
||||
});
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await RemoveFromLibrary(filePaths);
|
||||
this.selection.clear();
|
||||
} catch (error) {
|
||||
console.error('Error removing tracks from library:', error);
|
||||
notificationStore.persistent({
|
||||
title: 'Could not remove from library',
|
||||
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private onContextMenuFavoriteToggle() {
|
||||
const filePaths =
|
||||
this.selection.getSelectedKeysOrdered();
|
||||
@@ -2105,6 +2196,20 @@ export class TrackList
|
||||
></wa-icon>
|
||||
Track Details
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
@click=${() =>
|
||||
this.onContextMenuAction(
|
||||
'remove-from-library',
|
||||
)}
|
||||
@mouseenter=${() =>
|
||||
this.ctxMenu.closePlaylistSubmenu()}
|
||||
>
|
||||
<wa-icon
|
||||
slot="icon"
|
||||
name="trash"
|
||||
></wa-icon>
|
||||
Remove from Library
|
||||
</wa-dropdown-item>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -67,6 +67,17 @@ export const Events = {
|
||||
TrackMetadataChanged: "TrackMetadataChanged",
|
||||
BatchWriteProgress: "BatchWriteProgress",
|
||||
|
||||
// Track removal events.
|
||||
//
|
||||
// TracksRemovedFromLibrary means "these rows are gone and these paths
|
||||
// will not be imported again", and like TrackPlayCountChanged it
|
||||
// carries everything a consumer needs to patch rather than invalidate:
|
||||
// {filePaths: []string, count: int}. The library store splices those
|
||||
// paths out of its tracks array — which is the expensive collection —
|
||||
// and refetches only the album/artist/genre summaries, whose counts
|
||||
// really did change
|
||||
TracksRemovedFromLibrary: "TracksRemovedFromLibrary",
|
||||
|
||||
// Play statistics events.
|
||||
//
|
||||
// TrackPlayCountChanged carries everything needed to patch the one
|
||||
|
||||
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
|
||||
solid/arrows-rotate
|
||||
solid/arrow-up-short-wide
|
||||
solid/backward-step
|
||||
regular/bookmark
|
||||
solid/bookmark
|
||||
solid/box-open
|
||||
solid/check
|
||||
|
||||
@@ -438,9 +438,14 @@ async function dispatch(action: string): Promise<void> {
|
||||
);
|
||||
break;
|
||||
|
||||
// No `tracklist.delete`: it dispatched an event nothing
|
||||
// listened for, from a binding Settings advertised as
|
||||
// configurable. See backend/shortcuts/config.go.
|
||||
// `tracklist.delete` opens the confirmation and nothing else:
|
||||
// the key is a request, not an action. See
|
||||
// backend/shortcuts/config.go.
|
||||
case 'tracklist.delete':
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('shortcut:tracklist-delete'),
|
||||
);
|
||||
break;
|
||||
|
||||
// Panel-specific: autotag review. The view listens for these
|
||||
// while it is the view on screen, and for nothing while it is
|
||||
|
||||
@@ -121,6 +121,12 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
|
||||
scope: 'panel:track-list',
|
||||
defaultKey: 'Enter',
|
||||
},
|
||||
'tracklist.delete': {
|
||||
label: 'Remove from Library',
|
||||
category: 'Navigation',
|
||||
scope: 'panel:track-list',
|
||||
defaultKey: 'Delete',
|
||||
},
|
||||
'autotag.apply': {
|
||||
label: 'Apply Match',
|
||||
category: 'Autotag',
|
||||
|
||||
@@ -108,6 +108,9 @@ class LibraryStore {
|
||||
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
|
||||
this.applyPlayCount(payload);
|
||||
});
|
||||
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
|
||||
this.applyTracksRemoved(payload);
|
||||
});
|
||||
|
||||
this.loadCoverSize();
|
||||
this.deferEagerFetch();
|
||||
@@ -559,6 +562,71 @@ class LibraryStore {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice removed tracks out in place, and refetch only the
|
||||
* summaries whose counts changed.
|
||||
*
|
||||
* `invalidate()` would be correct and is the expensive answer: it
|
||||
* nulls `tracks` and eagerly refetches it, which is ~37 MB across
|
||||
* the IPC at 50 000 tracks for an operation that removed three
|
||||
* rows. The event carries the paths precisely so this does not have
|
||||
* to happen — the same bargain `TrackPlayCountChanged` makes.
|
||||
*
|
||||
* The album, artist and genre lists really do change (their track
|
||||
* counts, and the row itself when its last track goes), so they are
|
||||
* dropped and refetched. They are the small collections.
|
||||
*/
|
||||
private applyTracksRemoved(payload: unknown): void {
|
||||
const p = payload as { filePaths?: string[] } | null;
|
||||
const removed = p?.filePaths;
|
||||
|
||||
if (!removed || removed.length === 0) return;
|
||||
|
||||
// A tracks fetch already in flight would land holding the rows
|
||||
// that were just deleted, and it captured the cache generation
|
||||
// this patch is about to leave behind. There is no patch that
|
||||
// is equivalent to that, so fall back.
|
||||
if (this.inFlight.has('tracks')) {
|
||||
this.invalidate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tracks !== null) {
|
||||
const gone = new Set(removed);
|
||||
const kept = this.tracks.filter((t) => !gone.has(t.FilePath));
|
||||
|
||||
// A new array identity even when nothing matched would
|
||||
// invalidate every memoized filter/sort cache keyed on it
|
||||
// for no reason.
|
||||
if (kept.length !== this.tracks.length) {
|
||||
this.tracks = kept;
|
||||
}
|
||||
}
|
||||
|
||||
this.albums = null;
|
||||
this.artists = null;
|
||||
this.genres = null;
|
||||
// Bumping the cache generation is what stops an album fetch
|
||||
// issued before the removal from committing its pre-removal
|
||||
// answer. Safe for the tracks slot precisely because the guard
|
||||
// above established there is nothing in flight for it.
|
||||
this.cacheGen++;
|
||||
this.inFlight.delete('albums');
|
||||
this.inFlight.delete('artists');
|
||||
this.inFlight.delete('genres');
|
||||
|
||||
this.changeGen++;
|
||||
this.notify();
|
||||
|
||||
const logged = (what: string) => (err: unknown) =>
|
||||
console.error(`library: could not reload ${what}`, err);
|
||||
|
||||
void this.getAlbums().catch(logged('albums'));
|
||||
void this.getArtists().catch(logged('artists'));
|
||||
void this.getGenres().catch(logged('genres'));
|
||||
}
|
||||
|
||||
private invalidate(): void {
|
||||
this.tracks = null;
|
||||
this.albums = null;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import type { download } from '@go/models';
|
||||
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
|
||||
|
||||
/**
|
||||
* What the tick/hourglass/plus badge should say about one entity.
|
||||
*
|
||||
* This exists because the rule was written at all eight call sites and
|
||||
* so none of them had the whole of it: every one was a two-way ternary
|
||||
* between `in-library` and `not-in-library`, and the badge's third
|
||||
* state — `queued`, styled and labelled since it was written — was
|
||||
* produced by nothing. An album the user had already asked for through
|
||||
* the "Want this" button showed a plus and said it was not in their
|
||||
* library, on the same page, forty pixels from a filled button reading
|
||||
* "Wanted".
|
||||
*
|
||||
* Two rules decide the answer, and both are about honesty rather than
|
||||
* precedence for its own sake:
|
||||
*
|
||||
* - **Owning outranks wanting.** A request that has been satisfied by
|
||||
* any route — downloaded here, ripped, bought elsewhere — is not
|
||||
* news; what the user has is.
|
||||
* - **A request is by MBID, and the badge answers about the entity it
|
||||
* is on.** A track inside a requested album is not itself requested,
|
||||
* so it stays a plus. Saying otherwise would promise that clicking
|
||||
* it later would find *that* recording.
|
||||
*
|
||||
* A `satisfied` request is deliberately not `queued`: nothing is coming.
|
||||
* A `paused` one is, because the user did ask for it and it is still on
|
||||
* the list — "queued" is a slight overstatement of a paused request and
|
||||
* a much smaller one than "not in your library".
|
||||
*/
|
||||
export function libraryStatusFor(
|
||||
owned: boolean,
|
||||
mbid?: string | null,
|
||||
): LibraryStatus {
|
||||
if (owned) return 'in-library';
|
||||
|
||||
if (!mbid) return 'not-in-library';
|
||||
|
||||
const request = downloadStore.requestFor(mbid);
|
||||
|
||||
if (request && request.state !== 'satisfied') return 'queued';
|
||||
|
||||
return 'not-in-library';
|
||||
}
|
||||
|
||||
/** What a badge can ask for. Artists are deliberately absent: a
|
||||
* discography subscription is `explore-artist-details`'s Follow
|
||||
* button, which can say what it is committing to. */
|
||||
export type RequestableEntity = 'album' | 'track';
|
||||
|
||||
const ENTITY: Record<RequestableEntity, string> = {
|
||||
album: 'release-group',
|
||||
track: 'recording',
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or drop a request for one entity, and report which way it went.
|
||||
*
|
||||
* The counterpart to `libraryStatusFor`, here rather than in the badge
|
||||
* because the badge is one of several things that can ask —
|
||||
* `explore-album-details`'s "Want this" button is the other, and two
|
||||
* implementations of "what does wanting something mean" is exactly what
|
||||
* phase 1 was about.
|
||||
*
|
||||
* Returns `'wanted'` or `'cancelled'` so a caller can announce what
|
||||
* happened; throws if the backend refused, because a badge that
|
||||
* silently does nothing is what this whole plan is about.
|
||||
*/
|
||||
export async function toggleRequest(input: {
|
||||
mbid: string;
|
||||
entity: RequestableEntity;
|
||||
title: string;
|
||||
artist?: string;
|
||||
}): Promise<'wanted' | 'cancelled'> {
|
||||
const existing = downloadStore.requestFor(input.mbid);
|
||||
|
||||
if (existing) {
|
||||
await downloadStore.removeRequest(existing.id);
|
||||
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
// A request belongs to a library because that is where its files
|
||||
// will land. There is always at least one by the time anything is
|
||||
// on screen — the first-run wizard blocks every pointer event until
|
||||
// there is — but an explicit failure beats a request filed against
|
||||
// library 0, which no import would ever match.
|
||||
const libraryId = await libraryStore.getDefaultLibraryId();
|
||||
|
||||
if (!libraryId) throw new Error('no library to add this to');
|
||||
|
||||
await downloadStore.addRequest({
|
||||
mbid: input.mbid,
|
||||
entity: ENTITY[input.entity],
|
||||
libraryId,
|
||||
artist: input.artist ?? '',
|
||||
title: input.title,
|
||||
scope: 'future',
|
||||
secondary: false,
|
||||
} as download.RequestInput);
|
||||
|
||||
return 'wanted';
|
||||
}
|
||||
Reference in New Issue
Block a user