Merge branch 'main' into fix/small-issue-batch
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
VisibilityChangedEvent,
|
||||
} from '@lit-labs/virtualizer';
|
||||
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||
import { gridSpacingFor } from '@utils/grid-spacing';
|
||||
import {
|
||||
GetAlbumsByArtist,
|
||||
GetFilePathsByAlbums,
|
||||
@@ -147,8 +148,6 @@ export class ArtistsView
|
||||
|
||||
// ----- Grid spacing constants -----
|
||||
|
||||
private static readonly GRID_GAP = 8;
|
||||
private static readonly GRID_PADDING = 8;
|
||||
private static readonly CARD_PADDING = 5;
|
||||
|
||||
private get imageSize(): number {
|
||||
@@ -177,20 +176,41 @@ export class ArtistsView
|
||||
private createGridLayout() {
|
||||
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
|
||||
const h = w + this.cardTextHeight;
|
||||
const gap = ArtistsView.GRID_GAP;
|
||||
const pad = ArtistsView.GRID_PADDING;
|
||||
|
||||
// One number for the gap, the row gap and the padding: whatever
|
||||
// a row could not spend on another card, shared out equally, so
|
||||
// the outside is never wider than the inside. See
|
||||
// `utils/grid-spacing.ts`.
|
||||
const spacing = this.spacingFor(this.containerWidth);
|
||||
|
||||
this.lastLayoutSpacing = spacing;
|
||||
|
||||
return grid({
|
||||
itemSize: {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
},
|
||||
gap: `${gap}px`,
|
||||
padding: `${pad}px`,
|
||||
justify: 'center',
|
||||
gap: `${spacing}px`,
|
||||
padding: `${spacing}px`,
|
||||
justify: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
/** The width the grid lays itself out in. */
|
||||
private get containerWidth(): number {
|
||||
return (
|
||||
this.renderRoot?.querySelector<HTMLElement>(
|
||||
'.grid-scroll-container',
|
||||
)?.clientWidth ||
|
||||
this.clientWidth ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private spacingFor(width: number): number {
|
||||
return gridSpacingFor(width, this.cardSize);
|
||||
}
|
||||
|
||||
/** Sort direction for the artist grid.
|
||||
*
|
||||
* There is only one key to sort by: `library.Artist` carries a
|
||||
@@ -478,6 +498,8 @@ export class ArtistsView
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.detachWheelListener();
|
||||
this.gridResizeObserver?.disconnect();
|
||||
this.gridResizeObserver = null;
|
||||
}
|
||||
|
||||
/** The wheel listener and the scroll debounce belong to the grid
|
||||
@@ -730,10 +752,34 @@ export class ArtistsView
|
||||
* ================================================================ */
|
||||
|
||||
private lastLayoutWidth = 0;
|
||||
private lastLayoutSpacing = 0;
|
||||
|
||||
/** Watches the scroller so a window resize rebuilds the layout:
|
||||
* the spacing is derived from its width, and nothing else asks
|
||||
* this view to update when only that changes. */
|
||||
private gridResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private observeGridWidth() {
|
||||
const container =
|
||||
this.renderRoot?.querySelector<HTMLElement>(
|
||||
'.grid-scroll-container',
|
||||
);
|
||||
|
||||
if (!container || this.gridResizeObserver) return;
|
||||
|
||||
this.gridResizeObserver = new ResizeObserver(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
this.gridResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
private updateGridLayout() {
|
||||
this.observeGridWidth();
|
||||
|
||||
if (
|
||||
this.cardSize === this.lastLayoutWidth
|
||||
this.cardSize === this.lastLayoutWidth &&
|
||||
this.lastLayoutSpacing ===
|
||||
this.spacingFor(this.containerWidth)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,9 +86,10 @@ export class DownloadClients extends LitElement {
|
||||
/** Working copy of the auto-download guardrails. */
|
||||
@state()
|
||||
private prefs: download.AutoDownloadPrefs = {
|
||||
minSizeMb: 0,
|
||||
minKbps: 0,
|
||||
maxKbps: 0,
|
||||
preferredKbps: 0,
|
||||
maxSizeMb: 0,
|
||||
preferredSizeMb: 0,
|
||||
allowedFormats: [],
|
||||
} as download.AutoDownloadPrefs;
|
||||
|
||||
@@ -284,25 +285,72 @@ export class DownloadClients extends LitElement {
|
||||
: nothing}
|
||||
|
||||
<div class="form">
|
||||
<!-- Bitrate, not megabytes. A size means nothing
|
||||
on its own: 300 MB is a generous single and a
|
||||
suspiciously small boxset, and whoever fills
|
||||
this in has no idea which release it will be
|
||||
applied to. A rate is the same statement
|
||||
divided by how long the music is, so one number
|
||||
holds across an EP and an opera. -->
|
||||
<div class="field-row">
|
||||
<wa-input
|
||||
label="Minimum size (MB)"
|
||||
label="Minimum bitrate (kbps)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No minimum"
|
||||
.value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
|
||||
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
|
||||
minKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Maximum size (MB)"
|
||||
label="Maximum bitrate (kbps)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No maximum"
|
||||
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Preferred bitrate (kbps)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No preference"
|
||||
.value=${this.prefs.preferredKbps
|
||||
? String(this.prefs.preferredKbps)
|
||||
: ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
preferredKbps:
|
||||
Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
</div>
|
||||
|
||||
<div class="requires">
|
||||
320 is the top of MP3; a FLAC rip is usually
|
||||
500–1000 depending on the music. Preferred
|
||||
decides between copies that are otherwise equally
|
||||
good — it never rules one out, which is what the
|
||||
minimum and maximum are for.
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<wa-input
|
||||
label="Never grab more than (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No limit"
|
||||
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
@@ -311,22 +359,14 @@ export class DownloadClients extends LitElement {
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
<wa-input
|
||||
label="Preferred size (MB)"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="No preference"
|
||||
.value=${this.prefs.preferredSizeMb
|
||||
? String(this.prefs.preferredSizeMb)
|
||||
: ''}
|
||||
@input=${(e: Event) => {
|
||||
this.prefs = {
|
||||
...this.prefs,
|
||||
preferredSizeMb:
|
||||
Number((e.target as HTMLInputElement).value) || 0,
|
||||
};
|
||||
}}
|
||||
></wa-input>
|
||||
</div>
|
||||
|
||||
<div class="requires">
|
||||
A ceiling on the download itself, in case a
|
||||
mislabelled boxset gets through. Still a size
|
||||
because it is a question about disk space, and
|
||||
because it has to apply to a candidate whose
|
||||
bitrate cannot be worked out at all.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
|
||||
import { RovingGridController } from '@utils/roving-grid';
|
||||
import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import type { QueueSource } from '@store/queue-store';
|
||||
import '@awesome.me/webawesome/dist/components/popup/popup.js';
|
||||
@@ -97,19 +98,36 @@ export class CoverGrid
|
||||
private lastAlbumsRef: library.Album[] | null =
|
||||
null;
|
||||
|
||||
// Fixed grid spacing constants.
|
||||
private static readonly GRID_GAP = 8;
|
||||
private static readonly GRID_PADDING = 8;
|
||||
private static readonly CARD_PADDING = 5;
|
||||
|
||||
private ctxMenu = new ContextMenuController(this);
|
||||
private favCtrl = new FavoritesController(this);
|
||||
private selMgr = new AlbumSelectionManager();
|
||||
private scrollMgr = new ScrollManager(this, {
|
||||
GRID_GAP: CoverGrid.GRID_GAP,
|
||||
GRID_PADDING: CoverGrid.GRID_PADDING,
|
||||
columnsFor: (width: number) => this.columnsFor(width),
|
||||
spacingFor: (width: number) => this.spacingFor(width),
|
||||
});
|
||||
|
||||
/**
|
||||
* How many cards fit across `width`, by the same arithmetic the
|
||||
* virtualizer's `space-evenly` grid uses — no gap and no padding
|
||||
* are reserved, because both come out of what is left over.
|
||||
*
|
||||
* The scroll manager restores a position by rebuilding the grid's
|
||||
* geometry, so this and `spacingFor` must agree with the layout
|
||||
* rather than approximate it; they were two constants that no
|
||||
* longer describe anything once the spacing became elastic.
|
||||
*/
|
||||
columnsFor(width: number): number {
|
||||
return gridColumnsFor(width, this.cardWidth);
|
||||
}
|
||||
|
||||
/** The spacing that width produces: between columns, between rows,
|
||||
* and around the outside, all the same number. */
|
||||
spacingFor(width: number): number {
|
||||
return gridSpacingFor(width, this.cardWidth);
|
||||
}
|
||||
|
||||
private lastSelectedAlbumIndex: number | null = null;
|
||||
private lastSelectedTrackIndex: number | null = null;
|
||||
|
||||
@@ -148,10 +166,30 @@ export class CoverGrid
|
||||
}
|
||||
|
||||
// Virtualizer grid layout instance — recreated when
|
||||
// the card size changes.
|
||||
// the card size or the container width changes.
|
||||
private gridLayout = this.createGridLayout();
|
||||
private gridLayoutWidth = 0;
|
||||
|
||||
/** The spacing the current layouts were built with. */
|
||||
private gridLayoutSpacing = 0;
|
||||
|
||||
/** Watches the scroll container so a window resize rebuilds the
|
||||
* layout: the spacing is derived from its width, and nothing else
|
||||
* asks this component to update when only that changes. */
|
||||
private gridResizeObserver: ResizeObserver | null =
|
||||
null;
|
||||
|
||||
private observeGridWidth(): void {
|
||||
const container = this.scrollContainer;
|
||||
|
||||
if (!container || this.gridResizeObserver) return;
|
||||
|
||||
this.gridResizeObserver = new ResizeObserver(
|
||||
() => this.requestUpdate(),
|
||||
);
|
||||
this.gridResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary layout for the "after" virtualizer in
|
||||
* split mode. Uses zero top padding so there is no
|
||||
@@ -169,22 +207,49 @@ export class CoverGrid
|
||||
}
|
||||
|
||||
const h = w + this.cardTextHeight;
|
||||
const gap = CoverGrid.GRID_GAP;
|
||||
const pad = CoverGrid.GRID_PADDING;
|
||||
|
||||
// The spacing is whatever the row could not spend on another
|
||||
// card, shared out equally — so it is the same number between
|
||||
// two cards, between two rows, and down each outside edge.
|
||||
// See `utils/grid-spacing.ts` for why it is computed rather
|
||||
// than handed to the virtualizer as `space-evenly`.
|
||||
const spacing = this.spacingFor(
|
||||
this.containerWidth,
|
||||
);
|
||||
|
||||
if (!noTopPad) {
|
||||
this.gridLayoutSpacing = spacing;
|
||||
}
|
||||
|
||||
return grid({
|
||||
itemSize: {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
},
|
||||
gap: `${gap}px`,
|
||||
gap: `${spacing}px`,
|
||||
padding: noTopPad
|
||||
? `0 ${pad}px ${pad}px`
|
||||
: `${pad}px`,
|
||||
justify: 'center',
|
||||
? `0 ${spacing}px ${spacing}px`
|
||||
: `${spacing}px`,
|
||||
justify: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The width the grid lays itself out in.
|
||||
*
|
||||
* Read from the scroll container when there is one; before the
|
||||
* first render there is not, and the fallback only has to be
|
||||
* plausible — the layout is rebuilt from the real width as soon as
|
||||
* one exists.
|
||||
*/
|
||||
private get containerWidth(): number {
|
||||
return (
|
||||
this.scrollContainer?.clientWidth ||
|
||||
this.clientWidth ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private dragImageEl: HTMLElement | null = null;
|
||||
|
||||
// -- Memoisation caches for filtered albums --
|
||||
@@ -466,6 +531,9 @@ export class CoverGrid
|
||||
);
|
||||
this.wheelListenerAttached = false;
|
||||
|
||||
this.gridResizeObserver?.disconnect();
|
||||
this.gridResizeObserver = null;
|
||||
|
||||
this.scrollMgr.teardown();
|
||||
this.scrollMgr.revealContainer(
|
||||
this.scrollContainer,
|
||||
@@ -603,10 +671,18 @@ export class CoverGrid
|
||||
this.wheelListenerAttached = true;
|
||||
}
|
||||
|
||||
// Recreate the virtualizer grid layout when
|
||||
// the card size changes.
|
||||
this.observeGridWidth();
|
||||
|
||||
// Recreate the virtualizer grid layout when the card size
|
||||
// changes — or when the spacing the container width produces
|
||||
// does, since that is now a derived number rather than a
|
||||
// constant. Keyed on the spacing rather than on the width, or
|
||||
// every pixel of a drag rebuilds a layout that would come out
|
||||
// the same.
|
||||
const cardSizeChanged =
|
||||
this.gridLayoutWidth !== this.cardWidth;
|
||||
this.gridLayoutWidth !== this.cardWidth ||
|
||||
this.gridLayoutSpacing !==
|
||||
this.spacingFor(this.containerWidth);
|
||||
|
||||
if (cardSizeChanged) {
|
||||
this.gridLayout = this.createGridLayout();
|
||||
|
||||
@@ -6,12 +6,21 @@ import type { LibraryController } from '@store/controllers/library-controller';
|
||||
import type { GridEntry } from './cover-grid-types.js';
|
||||
|
||||
/**
|
||||
* Grid spacing constants shared between the scroll
|
||||
* manager and the host component.
|
||||
* Grid geometry, asked of the host rather than written down.
|
||||
*
|
||||
* These were two constants, `GRID_GAP` and `GRID_PADDING`, which stopped
|
||||
* describing anything the moment the grid's spacing became elastic: the
|
||||
* gap, the padding and the column count are all derived from the
|
||||
* container width now, and a scroll position rebuilt from a stale 8px
|
||||
* lands in the wrong row.
|
||||
*/
|
||||
export interface GridConstants {
|
||||
readonly GRID_GAP: number;
|
||||
readonly GRID_PADDING: number;
|
||||
/** Columns that fit across `width`. */
|
||||
columnsFor(width: number): number;
|
||||
|
||||
/** The spacing `width` produces — between columns, between rows,
|
||||
* and around the outside, all the same number. */
|
||||
spacingFor(width: number): number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -275,8 +284,8 @@ export class ScrollManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const gap = this.spacing(container);
|
||||
const pad = gap;
|
||||
const rowStep =
|
||||
this.host.cardHeight + gap;
|
||||
|
||||
@@ -293,7 +302,7 @@ export class ScrollManager {
|
||||
() => {
|
||||
const rowStep =
|
||||
this.host.cardHeight +
|
||||
this.gc.GRID_GAP;
|
||||
this.spacing(container);
|
||||
|
||||
if (this.pendingFocus === null) {
|
||||
this.isResizing = true;
|
||||
@@ -351,7 +360,7 @@ export class ScrollManager {
|
||||
container: HTMLElement,
|
||||
rowStep: number,
|
||||
): void {
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const pad = this.spacing(container);
|
||||
const cols = this.currentColumnCount;
|
||||
const filtered =
|
||||
this.host.cachedFilteredAlbums;
|
||||
@@ -410,17 +419,15 @@ export class ScrollManager {
|
||||
): number {
|
||||
if (!container) return 1;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const availableWidth =
|
||||
container.clientWidth - pad * 2;
|
||||
return this.gc.columnsFor(
|
||||
container.clientWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth + gap) /
|
||||
(this.host.cardWidth + gap),
|
||||
),
|
||||
/** The grid's current spacing, which is also its padding. */
|
||||
private spacing(container?: HTMLElement): number {
|
||||
return this.gc.spacingFor(
|
||||
container?.clientWidth ?? 800,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,7 +446,7 @@ export class ScrollManager {
|
||||
container?: HTMLElement,
|
||||
): number {
|
||||
const cols = this.getColumnCount(container);
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const gap = this.spacing(container);
|
||||
|
||||
return (
|
||||
cols * this.host.cardWidth +
|
||||
@@ -460,7 +467,7 @@ export class ScrollManager {
|
||||
|
||||
const cols = this.getColumnCount(container);
|
||||
const colIndex = idx % cols;
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const gap = this.spacing(container);
|
||||
|
||||
return (
|
||||
colIndex *
|
||||
@@ -597,8 +604,8 @@ export class ScrollManager {
|
||||
|
||||
if (!this.host.splitMode) return raw;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const gap = this.spacing(container);
|
||||
const pad = gap;
|
||||
const columns =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
@@ -678,8 +685,8 @@ export class ScrollManager {
|
||||
|
||||
if (expandedIndex < 0) return;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const gap = this.spacing(container);
|
||||
const pad = gap;
|
||||
const columns =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
@@ -772,8 +779,8 @@ export class ScrollManager {
|
||||
|
||||
if (idx < 0) return;
|
||||
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad = this.gc.GRID_PADDING;
|
||||
const gap = this.spacing(container);
|
||||
const pad = gap;
|
||||
const cols =
|
||||
this.getColumnCount(container);
|
||||
const rowStep = this.host.cardHeight + gap;
|
||||
@@ -854,9 +861,8 @@ export class ScrollManager {
|
||||
this.getExpandedAlbumIndex();
|
||||
|
||||
if (idx >= 0) {
|
||||
const gap = this.gc.GRID_GAP;
|
||||
const pad =
|
||||
this.gc.GRID_PADDING;
|
||||
const gap = this.spacing(container);
|
||||
const pad = gap;
|
||||
const cols =
|
||||
this.getColumnCount(
|
||||
container,
|
||||
|
||||
@@ -400,7 +400,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
private renderEmptyRequests() {
|
||||
return html`
|
||||
<div class="empty">
|
||||
Nothing requested yet. Use “Want this” on an album or artist
|
||||
Nothing requested yet. Use “Request this” on an album or artist
|
||||
to add it here.
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2694,7 +2694,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
||||
slot="start"
|
||||
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
|
||||
></wa-icon>
|
||||
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||
${this.isRequested ? 'Requested' : 'Request this'}
|
||||
</wa-button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2753,7 +2753,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
|
||||
slot="icon"
|
||||
name=${requested ? 'xmark' : 'bookmark'}
|
||||
></wa-icon>
|
||||
${requested ? 'Cancel Request' : 'Want This'}
|
||||
${requested ? 'Cancel Request' : 'Request This'}
|
||||
</wa-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@@ -1509,9 +1509,24 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
|
||||
if (url) {
|
||||
this.thumbnailCache.set(req.mbid, url);
|
||||
this.requestUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// An empty answer is not necessarily "there
|
||||
// is no art" — a slow Internet Archive node
|
||||
// is answered by a timeout, which looks
|
||||
// exactly the same from here. Drop the
|
||||
// in-flight marker so the next time this
|
||||
// release group is on screen it is asked
|
||||
// again; the backend records a genuine 404
|
||||
// on disk and answers that one instantly,
|
||||
// so a real miss costs nothing to re-ask.
|
||||
this.thumbnailCache.delete(req.mbid);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
this.thumbnailCache.delete(req.mbid);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
VisibilityChangedEvent,
|
||||
} from '@lit-labs/virtualizer';
|
||||
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
||||
import { gridSpacingFor } from '@utils/grid-spacing';
|
||||
import {
|
||||
GetFilePathsByGenres,
|
||||
} from '@go/library/library.js';
|
||||
@@ -155,8 +156,6 @@ export class GenresView
|
||||
|
||||
// ----- Grid spacing constants -----
|
||||
|
||||
private static readonly GRID_GAP = 8;
|
||||
private static readonly GRID_PADDING = 8;
|
||||
private static readonly CARD_PADDING = 5;
|
||||
|
||||
private get imageSize(): number {
|
||||
@@ -185,20 +184,41 @@ export class GenresView
|
||||
private createGridLayout() {
|
||||
const w = this.cardSize ?? CARD_SIZE_DEFAULT;
|
||||
const h = w + this.cardTextHeight;
|
||||
const gap = GenresView.GRID_GAP;
|
||||
const pad = GenresView.GRID_PADDING;
|
||||
|
||||
// One number for the gap, the row gap and the padding: whatever
|
||||
// a row could not spend on another card, shared out equally, so
|
||||
// the outside is never wider than the inside. See
|
||||
// `utils/grid-spacing.ts`.
|
||||
const spacing = this.spacingFor(this.containerWidth);
|
||||
|
||||
this.lastLayoutSpacing = spacing;
|
||||
|
||||
return grid({
|
||||
itemSize: {
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
},
|
||||
gap: `${gap}px`,
|
||||
padding: `${pad}px`,
|
||||
justify: 'center',
|
||||
gap: `${spacing}px`,
|
||||
padding: `${spacing}px`,
|
||||
justify: 'start',
|
||||
});
|
||||
}
|
||||
|
||||
/** The width the grid lays itself out in. */
|
||||
private get containerWidth(): number {
|
||||
return (
|
||||
this.renderRoot?.querySelector<HTMLElement>(
|
||||
'.grid-scroll-container',
|
||||
)?.clientWidth ||
|
||||
this.clientWidth ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private spacingFor(width: number): number {
|
||||
return gridSpacingFor(width, this.cardSize);
|
||||
}
|
||||
|
||||
/** Sort key and direction for the genre grid (H-19: it had none). */
|
||||
@state()
|
||||
private sortField: 'name' | 'tracks' = 'name';
|
||||
@@ -483,6 +503,8 @@ export class GenresView
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.detachWheelListener();
|
||||
this.gridResizeObserver?.disconnect();
|
||||
this.gridResizeObserver = null;
|
||||
}
|
||||
|
||||
/** See artists-view: off-screen the grid cannot be scrolled, and
|
||||
@@ -737,10 +759,34 @@ export class GenresView
|
||||
* ================================================================ */
|
||||
|
||||
private lastLayoutWidth = 0;
|
||||
private lastLayoutSpacing = 0;
|
||||
|
||||
/** Watches the scroller so a window resize rebuilds the layout:
|
||||
* the spacing is derived from its width, and nothing else asks
|
||||
* this view to update when only that changes. */
|
||||
private gridResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
private observeGridWidth() {
|
||||
const container =
|
||||
this.renderRoot?.querySelector<HTMLElement>(
|
||||
'.grid-scroll-container',
|
||||
);
|
||||
|
||||
if (!container || this.gridResizeObserver) return;
|
||||
|
||||
this.gridResizeObserver = new ResizeObserver(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
this.gridResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
private updateGridLayout() {
|
||||
this.observeGridWidth();
|
||||
|
||||
if (
|
||||
this.cardSize === this.lastLayoutWidth
|
||||
this.cardSize === this.lastLayoutWidth &&
|
||||
this.lastLayoutSpacing ===
|
||||
this.spacingFor(this.containerWidth)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export type LibraryStatus =
|
||||
*
|
||||
* Colours and glyphs:
|
||||
* - in-library → green circle, check mark
|
||||
* - queued → amber circle, hourglass
|
||||
* - queued → amber circle, bookmark ("on your list")
|
||||
* - not-in-library → grey circle, plus sign
|
||||
*
|
||||
* Usage:
|
||||
@@ -241,12 +241,23 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The glyph for each state.
|
||||
*
|
||||
* `queued` is a **bookmark**, not the hourglass it used to be. 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 user can leave one sitting on the list 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.
|
||||
*/
|
||||
private iconName(): string {
|
||||
switch (this.status) {
|
||||
case 'in-library':
|
||||
return 'check';
|
||||
case 'queued':
|
||||
return 'hourglass-half';
|
||||
return 'bookmark';
|
||||
default:
|
||||
return 'plus';
|
||||
}
|
||||
@@ -276,7 +287,7 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
if (this.actionable) {
|
||||
return this.status === 'queued'
|
||||
? `Cancel the request for ${kind}${name}`
|
||||
: `Want ${kind}${name}`;
|
||||
: `Request ${kind}${name}`;
|
||||
}
|
||||
|
||||
switch (this.status) {
|
||||
@@ -354,25 +365,6 @@ export class LibraryStatusIndicator extends LitElement {
|
||||
}
|
||||
|
||||
const title = this.tooltip();
|
||||
const icon = 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}
|
||||
>
|
||||
${icon}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// The ring stands in for the icon wherever the icon would go —
|
||||
// including inside the button, because a partly-held album is
|
||||
|
||||
@@ -307,7 +307,7 @@ export class NowPlayingView extends LitElement {
|
||||
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
|
||||
@click=${this.toggleFavorite}
|
||||
>
|
||||
<wa-icon name=${this.favCtrl.iconName}></wa-icon>
|
||||
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ export class NowPlaying extends LitElement {
|
||||
)}
|
||||
>
|
||||
<wa-icon
|
||||
name=${this.favCtrl.iconName}
|
||||
name=${this.favCtrl.iconFor(isFav)}
|
||||
variant=${favVariant}
|
||||
></wa-icon>
|
||||
</button>
|
||||
|
||||
@@ -1756,7 +1756,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
${entry.summary.ID === this.favCtrl.playlistId
|
||||
? html`<wa-icon
|
||||
class="playlist-icon"
|
||||
name=${this.favCtrl.iconName}
|
||||
name=${this.favCtrl.iconFor(true)}
|
||||
></wa-icon>`
|
||||
: entry.summary.IsSmart
|
||||
? html`<wa-icon
|
||||
|
||||
Reference in New Issue
Block a user